Files
PiCloud/E2E_TODO_REPORT.md
MechaCat02 30bad27711 docs(e2e): record second CLI-only attempt — all gaps verified closed
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>
2026-06-13 09:59:11 +02:00

14 KiB
Raw Permalink Blame History

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 raw curl against the admin API (app todoapp2, domain todos2.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.rhaiusers::create
POST /auth/login login.rhaiusers::login → session token
GET /todos todos_list.rhaidocs.find({user_id})
POST /todos todos_create.rhaidocs.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 / deleteno 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, verify resolves it (sliding TTL), password hashing + email uniqueness enforced.
  • docs::collection(...).find({field}) filtered correctly; full-blob update semantics 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 deploy updates in place (v1→v2, same id) — no duplicate scripts on redeploy.
  • pic routes match resolved param routes and captured param.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-clitarget/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 --externalexternal_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.