Files
PiCloud/E2E_TODO_REPORT.md
MechaCat02 04a24ea0b7 feat(cli): close E2E To-Do CLI gaps (B1, B2, F2, F1, F3)
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>
2026-06-12 20:43:03 +02:00

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.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.