A CLI-only walkthrough (E2E_TODO_REPORT.md) found two control-plane
operations with no `pic` command — every CLI-created app 404'd until it
claimed a domain, and external SSE feeds needed a raw topic-registration
call — plus several friction points. All admin APIs already existed; this
adds thin wrappers mirroring `routes`/`triggers`:
- B1: `pic apps domains {ls,add,rm}` over apps/{id}/domains.
- B2: `pic topics {ls,create,update,rm}` over apps/{id}/topics, and a
triggers help note distinguishing a pubsub trigger from topic
registration.
- F2: `pic users {ls,show,reset-password,revoke-sessions}` over the app
end-user admin surface (read + the two admin actions; create/invitations
deferred).
- F1: `apps create` and `deploy` now honor `--output json`, emitting the
created object so scripts can capture the id.
- F3: non-interactive login via `--username` + `--password-stdin` (inline
passwords still rejected, mirroring the `--token` rule).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
10 KiB
E2E Developer Test — Rich To-Do App via the pic CLI
Date: 2026-06-12 · Tester: manual end-to-end walkthrough · Build: v1.1.9
(product 1.1.9, sdk 1.10, api 1, schema 42) · Instance: host-run picloud
in dev mode on 127.0.0.1:8099, Postgres via docker compose up postgres.
Goal
Act as a developer building a real product — a multi-user To-Do app (end-user
signup/login, per-user todos in document storage, full HTTP CRUD, a nightly cron cleanup,
and a realtime activity feed) — entirely through the pic CLI, and record every place
the happy path forced me off the CLI or behaved surprisingly.
Verdict
The platform can build and run the whole app — every capability exists and works. The
data-plane journey (register → login → create → list → complete → ownership-checked delete →
auth rejection) is correct, users::* auth is solid, docs::* storage works, durable pubsub
fans out to an external SSE subscriber, and the cron trigger registers and runs.
But a CLI-only developer cannot reach the finish line. Two control-plane operations have
no pic command at all and forced me to drop to raw curl against the admin API — and
without the first one, every app created through the CLI serves 404 on every route.
App as built
| Surface | Implementation |
|---|---|
POST /auth/register |
register.rhai → users::create |
POST /auth/login |
login.rhai → users::login → session token |
GET /todos |
todos_list.rhai → docs.find({user_id}) |
POST /todos |
todos_create.rhai → docs.create + pubsub::publish_durable("activity", …) |
PATCH /todos/:id |
todos_update.rhai → ownership check + complete + publish |
DELETE /todos/:id |
todos_delete.rhai → ownership check + delete |
cron 0 0 3 * * * |
cleanup.rhai → purge completed todos older than 7d |
| realtime | SSE GET /realtime/topics/activity |
7 scripts, 6 routes, 1 cron trigger, 1 app, 1 domain claim, 1 pubsub topic. Sources in
/tmp/todo-app/*.rhai.
BLOCKERS — no CLI path; app cannot serve traffic without raw API
B1. No domain-claim command — every CLI-created app 404s until you curl
Severity: BLOCKER (highest-impact finding).
After pic apps create, deploying scripts, and binding routes, every request 404'd:
$ curl -s -X POST localhost:8099/auth/register -d '{...}'
{"error":"no route matches POST /auth/register"}
Reason: Host→app dispatch resolves localhost to the default app; a non-default app's
routes are unreachable until the app claims a domain. But pic apps exposes only
ls / create / show / delete — no domain subcommand — and there is no top-level
pic domains either. The only way through is the raw admin API:
curl -X POST localhost:8099/api/v1/admin/apps/$APP/domains \
-H "authorization: Bearer $TOKEN" -d '{"pattern":"todos.local"}'
After that, routing works (with Host: todos.local). A developer who only knows the CLI is
hard-stuck here — they have created scripts and routes that silently never match, with no CLI
affordance explaining why. Fix: add pic apps domains {ls,add,rm} (the API already exists in
apps_api.rs).
B2. No pubsub topic-registration command — realtime feed needs raw API
Severity: BLOCKER for the realtime requirement.
Scripts can pubsub::publish_durable("activity", …) fine (publishing needs no pre-registration),
but an external SSE subscriber on /realtime/topics/activity only receives events once the
topic is registered as external_subscribable. There is no pic topics command. Worse, the
pic triggers help text actively points you at create-from-json "for kinds the CLI doesn't
expose … (docs/files/pubsub/…)" — but that creates a pubsub trigger (run a script on publish),
which is a different concept and does not register a topic for outside subscribers. Workaround:
curl -X POST localhost:8099/api/v1/admin/apps/$APP/topics \
-H "authorization: Bearer $TOKEN" \
-d '{"name":"activity","external_subscribable":true,"auth_mode":"public"}'
Once registered, the SSE path works perfectly — a subscriber received:
data: {"message":{"kind":"created","title":"Realtime test", …},"topic":"activity", …}
Fix: add pic topics {ls,create,update,rm} (API exists in topics_api.rs).
FRICTION — completable via CLI, but sharp edges
F1. pic deploy / apps create ignore --output json
They print human strings even in JSON mode, so you can't capture the new id:
$ pic --output json deploy register.rhai --app todos-e2e
Created register v1 # not JSON — no id emitted
$ pic --output json apps create todos-e2e
Created app todos-e2e # not JSON — no id emitted
Every later routes/triggers/domain/topic call needs that id, so each create forces a
follow-up scripts ls --output json | parse or apps show. This breaks naive scripting/CI.
Fix: emit the created object as JSON under --output json.
F2. No pic users command for app end-users
The full /api/v1/admin/apps/{id}/users surface exists (list, get, reset-password,
revoke-sessions, invitations) but has no CLI wrapper, so a developer has zero CLI visibility
into who registered. Listing the two users I created required raw API. Fix: pic users ….
F3. Password login is interactive-only
pic login offers --url and --token but no --username/--password. Non-interactive
(CI) auth means obtaining a bearer out-of-band (raw POST /auth/login, or pre-minting an
pic api-keys token — chicken-and-egg if you have no token yet). Fix: optional
--username + --password-stdin.
F4. No ctx.request.method in scripts → one script per verb
The request map exposes path, headers, body, params, query, rest but not the HTTP method
(engine.rs builds the map without it). A script therefore cannot branch GET vs POST on the
same path, forcing a separate script + method-scoped route per verb. My app needed 7 scripts
where ~3 would do (/todos GET+POST and /todos/:id PATCH+DELETE each had to split).
Fix: add ctx.request.method.
SDK / SCRIPTING SHARP EDGES — platform correct, but the natural code fails at runtime
S1. users::find_by_email is forbidden for anonymous (public) callers
The obvious registration pattern — "look up email, 409 if taken, else create" — fails:
{"error":"Runtime error: users: forbidden"} # HTTP 502
find_by_email deliberately requires an authenticated principal (anti-enumeration, audit
finding F-S-003 in users_service.rs), while users::create is allowed for anonymous public
scripts. So a self-serve register script must skip the pre-check and rely on create's
uniqueness error instead. The SDK doc-comment for find_by_email shows it with no caveat, so
this only surfaces as a runtime 502. Fix: document the principal requirement on
find_by_email; consider a dedicated users::email_available() that's safe for anonymous use.
S2. Response envelope is statusCode-gated (silent double-nesting)
A returned map is unwrapped as {statusCode, headers, body} only if it contains
statusCode. Without it, the entire map becomes the literal body — so #{ body: #{token} }
returns {"body":{"token":…}}, not {"token":…}. I hit this on login/list/update until I added
statusCode: 200. The doc-comment examples don't flag it. Fix: doc note, or treat a lone
body key as an envelope.
S3. Rhai String.replace() mutates in place and returns ()
let token = auth.replace("Bearer ", "") sets token to unit, then users::verify(()) →
Function not found: users::verify (()). Stock Rhai semantics, but a JS/Python dev will hit it;
auth.sub_string(7) after starts_with("Bearer ") is the correct idiom. *Fix: a one-line note
- a bearer-parsing example in the stdlib reference.*
ONBOARDING
O1. Dev mode needs a second, undocumented acknowledgement var
PICLOUD_DEV_MODE=true alone aborts at startup:
Error: PICLOUD_DEV_MODE=true without PICLOUD_SECRET_KEY requires an explicit acknowledgement.
Set PICLOUD_DEV_INSECURE_KEY=i-understand-this-is-insecure to confirm …
CLAUDE.md and the dev notes mention only PICLOUD_DEV_MODE=true. Good security default, but the
error message is the sole documentation. Fix: document PICLOUD_DEV_INSECURE_KEY next to
PICLOUD_DEV_MODE.
What worked well (no changes needed)
- End-to-end data plane fully correct: register, login, per-user list, create, complete, delete, ownership 403 (Bob can't touch Alice's todo), 401 on missing/garbage token.
users::*: login returns a 43-char session token,verifyresolves it (sliding TTL), password hashing + email uniqueness enforced.docs::collection(...).find({field})filtered correctly; full-blobupdatesemantics as documented.pubsub::publish_durable→ external SSE delivery worked once the topic was registered.- Cron trigger registered;
pic invoke <cleanup-id>ran it →{"removed":0}. pic deployupdates in place (v1→v2, same id) — no duplicate scripts on redeploy.pic routes matchresolved param routes and capturedparam.id; empty param segment (/todos/) correctly did not match.pic logs <id>listed executions with success/error status.
Reproduction
Server: docker compose up -d postgres; then host-run with
PICLOUD_DEV_MODE=true PICLOUD_DEV_INSECURE_KEY=i-understand-this-is-insecure PICLOUD_BIND=127.0.0.1:8099 PICLOUD_ADMIN_USERNAME=admin PICLOUD_ADMIN_PASSWORD=admin target/debug/picloud. CLI: cargo build -p picloud-cli → target/debug/pic. App scripts:
/tmp/todo-app/*.rhai. Every command + captured output above was run live against this instance.
Priority recommendation
The two CLI gaps that turn "I built an app" into "…but it 404s and has no realtime" are B1
(domain claims) and B2 (topic registration). Both already have working admin APIs; they
just need thin pic wrappers (mirroring the existing routes/triggers commands). Ship those
two and a CLI-only developer can build this entire app without ever touching curl.