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>
196 lines
10 KiB
Markdown
196 lines
10 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.
|
|
|
|
---
|
|
|
|
## 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`.
|