Rebuilt the rich To-Do app end-to-end through `pic` alone (no raw admin curl) after the gap fixes landed, and appended a per-finding verification table + transcript to the report. Confirms B1/B2/F1–F4/S1–S3/O1 closed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
14 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.
✅ RE-TEST 2026-06-12 — ALL GAPS CLOSED (verified live)
After remediation (commits
04a24ea,ae2134e,7ffbb8d,b831138), I rebuilt the entire app a second time CLI-only, with zero rawcurlagainst the admin API (apptodoapp2, domaintodos2.local). Every finding below is now fixed and verified end-to-end. See the Re-test verification section at the bottom for the per-finding evidence. The original findings are preserved as-is for the record.
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.
Re-test verification — 2026-06-12
Second pass after the fix commits (04a24ea CLI gaps, ae2134e ctx.request.method,
7ffbb8d users::email_available, b831138 docs). I rebuilt the whole app as a fresh,
CLI-only flow against the same live instance — new app todoapp2, domain todos2.local,
no raw curl to any /api/v1/admin/* endpoint. Data-plane HTTP calls (the app's own
traffic) still use curl, as a real end-user client would.
Status table
| # | Finding | Status | Evidence |
|---|---|---|---|
| B1 | No domain-claim command | ✅ Fixed | pic apps domains add todoapp2 todos2.local → claim created; routes then match. Help text even explains the 404-without-claim trap. |
| B2 | No pubsub topic command | ✅ Fixed | pic topics create --app todoapp2 activity --external → external_subscribable:true; SSE subscriber received the published event. |
| F1 | deploy/apps create ignore --output json |
✅ Fixed | Both now emit the full JSON object; I captured every script id straight from deploy --output json (no follow-up scripts ls). |
| F2 | No pic users command |
✅ Fixed | pic users ls/show/reset-password all work; listed Carol+Dave, showed Carol, minted a 1-shot reset token. |
| F3 | Password login interactive-only | ✅ Fixed | printf 'admin' | pic login --username admin --password-stdin logged in non-interactively. |
| F4 | No ctx.request.method |
✅ Fixed | One todos.rhai now serves GET+POST and one todo_item.rhai serves PATCH+DELETE by branching on ctx.request.method; unsupported verb returns my 405. Script count dropped 7→5, routes 6→4. |
| S1 | find_by_email forbidden for anon registration |
✅ Fixed | New users::email_available(email) → bool works from the anonymous register route; duplicate email now returns a clean 409, no more users: forbidden/502. |
| S2 | statusCode-gated envelope (double-nest) |
✅ Documented | Behaviour noted in stdlib docs (b831138); my scripts use explicit statusCode and responses are flat. |
| S3 | Rhai replace() returns () footgun |
✅ Documented | Bearer-parsing note added; auth.sub_string(7) after starts_with is the idiom used. |
| O1 | Dev-mode needs undocumented ack var | ✅ Documented | PICLOUD_DEV_INSECURE_KEY now documented alongside PICLOUD_DEV_MODE. |
CLI-only build transcript (abridged, all succeeded)
pic login --username admin --password-stdin # F3
pic apps create todoapp2 --output json # F1 → captured id
pic apps domains add todoapp2 todos2.local # B1
pic deploy <f>.rhai --app todoapp2 --output json # F1 → captured 5 script ids
pic routes create --script <id> --path /todos # ANY-method route (F4 in-script branch)
pic routes create --script <id> --path /todos/:id --path-kind param
pic topics create --app todoapp2 activity --external # B2
pic triggers create-cron --app todoapp2 --script <cleanup> --schedule "0 0 3 * * *"
pic users ls --app todoapp2 # F2
Data-plane journey (all green)
register carol/dave 201 / 201
duplicate carol (email_available, S1) 409 {"error":"email already registered"}
login carol/dave 43-char tokens
POST /todos (method-branch create, F4) 201 ×2
GET /todos (same script, list, F4) 200 count:2
PATCH /todos/:id complete 200
dave PATCH/DELETE carol's todo 403 / 403 (ownership)
carol DELETE 200
PUT /todos (unsupported verb) 405 (in-script method guard)
no-auth GET /todos 401
realtime SSE on activity received {"kind":"created",...,"topic":"activity"}
Conclusion: a CLI-only developer can now build this entire app — auth, storage, CRUD,
ownership, cron, and a realtime feed — without ever touching curl against the admin API.