# 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](#re-test-verification--2026-06-12) 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, `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 ` 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 ` 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 .rhai --app todoapp2 --output json # F1 → captured 5 script ids pic routes create --script --path /todos # ANY-method route (F4 in-script branch) pic routes create --script --path /todos/:id --path-kind param pic topics create --app todoapp2 activity --external # B2 pic triggers create-cron --app todoapp2 --script --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.**