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>
262 lines
14 KiB
Markdown
262 lines
14 KiB
Markdown
# 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 <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-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.**
|