arch-scaffold-app ยท diff

git:20260921.707637b to git:20260922.7464ce7

2 added, 2 removed. Audit A to A.

---
name: arch-scaffold-app
description: "Create a client app the way the Software Design and Architecture Guidelines prescribe: a browser app (portal or operator console) with generated API types behind a facade, one shared transport client, TanStack Query and Zustand, a single realtime channel with typed envelopes, and the view, view-model, model split; or a Python CLI over REST with idempotency keys and exit codes. Stack: TypeScript (React, Vite) or Python."
allowed-tools: Read, Grep, Glob, Write, Edit, Bash(make openapi), Bash(make check), Bash(pnpm install:*), Bash(pnpm run:*), Bash(pnpm --filter:*), Bash(uv run:*), Bash(uv sync:*), Bash(git status:*)
---
# arch-scaffold-app
Conventions: `${CLAUDE_SKILL_DIR}/../_shared/scaffold-conventions.md`.
Sections of `${CLAUDE_SKILL_DIR}/../../architecture.md`: The Network
Layer (Clients Live in One Place, Direction of Calls, Realtime at the
Edge), Apps (Apps Are Dumb, Push-First Apps), Client App
Architecture (Stack; State and Data; Views, View-Models, Models; API
Access; One Tenant at a Time; Realtime: One Channel per App; The
Operator Console; The CLI Is Different), Deployment (Cloud: AWS), Monorepo Folder Structure
(Layout Conventions).
## Input
`<app-name> --kind portal|admin|cli`
`--kind` is required; ask for it when missing. `portal` is the
product's browser app with the realtime channel; `admin` is the
operator console, same stack and design kit, no socket, routes under
`/v1/admin/*`, its own origin; `cli` is a Python command-line app over
REST. `<app>` is the app name in snake case.
## Created
Browser API client, under `apps/<app-name>/src/api/` as the
guideline's tree places it (the portal creates it; the console
imports the portal's, as it imports the portal's design kit):
| File | Holds |
|-----------------------|-----------------------------------------------------------------------------------------|
| `openapi.json` | at `apps/<app-name>/openapi.json`: the committed contract, regenerated by `make openapi` |
| `src/api/schema.d.ts` | generated by `openapi-typescript` (a devDependency and a `generate` script in the app's `package.json`), never hand-edited |
| `src/api/types.ts` | the facade: `import type { components } from "./schema"; type Schemas = components["schemas"]; export type XView = Schemas["XView"];` |
| `src/api/client.ts` | one transport client: the API origin and the request timeout taken from the app's config (no call goes out without the timeout), the bearer read from the session store (held in memory and mirrored to the tab's session storage, never local storage), app header, error envelope parsed into a typed error carrying the request id, 401 clears authentication; the app's one retry lives here: only a failure that can differ on a second attempt (a timeout, a connection refused, an unavailable answer) is retried, bounded in count and spaced by a delay that grows and carries jitter, with the query library's own retry turned off where it is configured, so retries do not stack, as The Network Layer (Direction of Calls) states |
| `src/api/index.ts` | re-exports `types` and `client` |
Python client, under `clients/python/` (created when absent, and
skipped when present: `arch-scaffold-new` writes it after its first
`make openapi`, whether or not it builds a portal, because the ops
package rides it; the CLI and a remote service impl import it):
| File | Holds |
|-----------------------|-----------------------------------------------------------------------------------------|
| `pyproject.toml`, `src/<root>/clients/api/` | `<root>-client`: one typed client over `httpx` generated from the committed OpenAPI document, the bearer or the internal credential, the app header, the error envelope parsed into a typed error carrying the request id, the OS trust store; `Idempotency-Key` on every creating call; the base URL, the credential, and the timeout arrive through its constructor, never from the environment, and no call goes out without the timeout; the retry lives here too and nowhere above it: only a failure that can differ on a second attempt is retried, bounded in count and spaced by a delay that grows and carries jitter, its count and its delay arriving through the constructor with the rest, so no caller wraps this client in a second retry |
Browser app (`portal` or `admin`), under `apps/<app-name>/`:
| File | Holds |
|----------------------------------------------------------|---------------------------------------------------------------------------|
| `package.json`, `tsconfig.json`, `vite.config.ts`, `eslint.config.js` | the workspace package depending on `@sentry/react` (the console also on the portal's package, for its `src/api/` and its design kit), strict TypeScript, the Vite build, `vitest` with a `test` script, and the two lint rules that enforce Procedure 1 (no `fetch`, no import of `schema.d.ts` in feature code) |
| `src/config.ts` | loads `/config.json` once before the first render (API origin, request timeout, error-tracking DSN, environment name) and exposes it typed; no `import.meta.env` value that differs between environments |
| `src/main.tsx`, `src/app/App.tsx`, `src/app/routes.tsx` | the shell and the routes; `main.tsx` awaits the config, then initializes the Sentry SDK only when the DSN is set, reporting from the React root's error callbacks and from each route's error element |
| `src/app/RequireAuth.tsx` (portal) or `src/app/RequireAdmin.tsx` (admin) | the sign-in gate; the console renders the API's own refusal |
| `src/features/sign_in/` (portal) | the sign-in screen the gate renders, in the same split as every screen: login (`POST /v1/auth/login`) and sign-up (`POST /v1/auth/signup`, the same answer), then the membership choice from the answer's `memberships` (one goes straight in, several show the picker before the first screen, none show a plain message), then the exchange for a tenant session (`POST /v1/auth/exchange` with the `org_id`), after which the login credential is dropped and only the session is held |
| `src/features/org_chip/` (portal) | the org chip in the app's chrome, in the same split: the current org's name; with more than one membership it opens the list (`GET /v1/auth/memberships`) and switches by a second exchange presenting the current session, then, before the new session is used, clears the query cache and every tenant store entry and reopens the realtime socket |
| `src/design/tokens.ts` and `src/design/kit/` (first browser app only) | design tokens and a minimal component kit; the second app imports the first app's |
| `src/queries/keys.ts`, `src/queries/<domain>.ts` | the query-key factory (entity name first in every key, so the envelope router invalidates by name) and one TanStack Query hooks module per domain |
| `src/store/<domain>.ts` | one Zustand store per client-state domain; the session and the connection state are two; the session store holds the bearer in memory and mirrors it to `sessionStorage`, so a reload survives and a closed tab forgets, and never touches `localStorage`; it holds one bearer and the current org, never two sessions |
| `src/realtime/RealtimeProvider.tsx`, `envelopes.ts`, `router.ts`, `timeouts.ts` (portal only) | the one socket, the discriminated union on `type` (mirroring the service's `envelopes.py`), the router into the query cache, the last contiguous `seq` as the cursor (a gap is a replay from it, never a skip) and the replay from `GET /v1/events?after_seq=` on reconnect or on a gap, the ping interval read from the shared timeouts file |
| `src/features/<screen>/<Screen>Page.tsx`, `use<Screen>Vm.ts`, `<screen>Model.ts`, `<screen>Model.test.ts` | one screen per entity the API hosts, plus the home screen, each in the view, view-model, model split |
| `README.md` | the app's conventions in one page |
CLI, under `apps/<app-name>/`:
| File | Holds |
|-------------------------------------------|---------------------------------------------------------------------------|
| `pyproject.toml` | the distribution, `typer`, and `<root>-client` as a workspace source; a console entry point |
| `src/<root>/apps/<app>/__init__.py` | empty |
| `src/<root>/apps/<app>/settings.py` | one `BaseSettings` under the product prefix (API URL, token, request timeout, home), read once at the start of `main` and handed to the client's constructor; nothing below `main` reads `os.environ` |
| `src/<root>/apps/<app>/main.py` | the command groups, one per namespace; `main` builds the settings and the client once and hands them down |
| `src/<root>/apps/<app>/listen.py` (when the API has a socket) | the feed command over the realtime channel: it keeps the last contiguous `seq` from the hello frame and each push, and replays from `GET /v1/events?after_seq=` on a gap or a reconnect, never skipping |
| `src/<root>/apps/<app>/follow.py` | submit-and-follow for long operations, exit code from the outcome |
| `tests/test_cli.py` | commands over a stubbed client |
## Changed
| File | Change |
|-----------------------------------------|--------------------------------------------------------------------------|
| `.env.example` (browser app) | the app's local dev server origin added to the API's allowed origins |
| `pnpm-workspace.yaml` (browser app) | `apps/*` and `clients/*` listed |
| `package.json` (root, browser app) | the workspace scripts for lint, typecheck, and test |
| `Makefile` | the `openapi` target writes `apps/<portal>/openapi.json` and runs `generate`, and regenerates `clients/python/` (already so on a tree `arch-scaffold-new` built); for a browser app, `check` also runs the workspace lint, typecheck, and test scripts, so CI's `make check` covers the app |
| `deployment/realtime-timeouts.json` (portal, when absent) | the ping interval and load balancer idle timeout; the service scaffold writes it with `--realtime`, and the portal asserts it from the client side, in `timeouts.ts` and its test, the client half of the shared-file rule |
- | `deployment/terraform/modules/static-site/` (browser app, when absent) | a private S3 bucket with public access blocked, a CloudFront distribution reading it through origin access control, `index.html` as the fallback for client routes, `config.json` served uncached, a response headers policy on the distribution declaring `Content-Security-Policy` (the app's own origin, the API, the error tracker's origin when one is configured, and the object store's origin when uploads are presigned, and nothing else) with the other security headers, the alias `<subdomain>.<base_domain>` with its certificate and DNS record |
+ | `deployment/terraform/modules/static-site/` (browser app, when absent) | a private S3 bucket with public access blocked, a CloudFront distribution reading it through origin access control, `index.html` as the fallback for client routes, `config.json` served uncached, a response headers policy on the distribution declaring `Content-Security-Policy` (the app's own origin, the API, the error tracker's origin when one is configured, and the object store's origin when uploads are presigned, and nothing else) with the other security headers, the alias `<subdomain>.<base_domain>` with its certificate, issued in `us-east-1` whatever the environment's region because CloudFront reads certificates from there alone, and its DNS record |
| `deployment/terraform/environments/*/` (browser app) | one `static-site` instance for the app in every environment, subdomain `app` (portal) or `admin` (console), its origin added to the API's allowed origins, its bucket and origin as outputs |
- | `.github/workflows/deploy-staging.yml`, `deploy-production.yml` (browser app) | the staging workflow builds the app once under the build id (the commit), keeps the bundle as a workflow artifact, syncs it to staging's bucket, writes staging's `config.json` from the Terraform outputs, and invalidates the distribution; the production workflow, in the job behind the approval on the plan, syncs the bundle staging built for the release commit to production's bucket and writes production's `config.json`, never building |
+ | `.github/workflows/deploy-staging.yml`, `deploy-production.yml` (browser app) | the staging workflow builds the app once under the build id (the commit), keeps the bundle by commit in staging's artifacts bucket, which replicates it into production's, syncs it to staging's bucket, writes staging's `config.json` from the Terraform outputs, and invalidates the distribution; the production workflow, in the job behind the approval on the plan, syncs the bundle staging built for the release commit from production's artifacts bucket to production's bucket and writes production's `config.json`, never building |
| `pyproject.toml` (root, CLI) | the member added to `[tool.uv.workspace] members` |
| `scripts/dev.sh` | writes the app's `public/config.json` (ignored by git) from `.env`, then starts the browser app's dev server |
| `README.md` (root, browser app) | a row in the `Local URLs` table: the app's dev server at `http://localhost:<port>` |
## Procedure
1. Browser app: feature code never calls `fetch` and never imports
`schema.d.ts`; everything goes through `src/api/`, and every call
carries the timeout the config names and the one retry the client
owns, never a second one in a hook. The bearer lives in memory
and in the tab's session storage, never in local storage, as Client
App Architecture (API Access) states. A view never
re-implements a domain rule to enable or disable an action (the
last owner cannot be removed, the password rule): the service
exposes the decision as a flag on the view, or the client acts on
the error envelope the server returns, as Apps (Apps Are Dumb)
states.
2. Portal: every realtime envelope routes into the query cache, never
into components; the provider owns reconnection with backoff and the
degraded polling mode with its banner.
3. Portal: the app works in one tenant at a time, as Client App
Architecture (One Tenant at a Time) states. No screen past the gate
renders before a membership is chosen and exchanged. A switch is a
second exchange that ends the presented session; the new session
replaces the old in the session store, the query cache and every
tenant store are cleared, and the socket reopens under the new
session before the first request of the new tenant.
4. Console: no socket, no tenant context, no membership picker and no
org chip; the portal's sign-in form without its exchange, since the
operator plane admits only the login credential; the operator gate
rendered from the API's own refusal.
5. CLI: every call goes through `<root>-client`; creating calls send
`Idempotency-Key`; followed operations poll at a fixed cadence and
exit non-zero on failure; the settings are read once at the start
of `main` and reach the client through its constructor, as
Cross-Cutting Conventions (Configuration) states for every process.
Its API key is scoped to one membership, so it takes no org choice
and holds one credential.
6. Browser app: the app is not done until every Terraform environment
declares its bucket, distribution, subdomain, and the
`Content-Security-Policy` beside the distribution, the API allows
its origin, and the deploy workflow ships its bundle, built once and
promoted; a browser app with no cloud deployment is incomplete.
7. Browser app: add the package to the pnpm workspace and run
`pnpm install`, then `make openapi`, so `openapi.json` and the
generated `schema.d.ts` exist before the first screen is written.
CLI: add the member to the uv workspace and run `uv sync`.
8. Browser app: run `make check`, which the Makefile row made cover
the app's lint, typecheck, and tests.
## Output
As `${CLAUDE_SKILL_DIR}/../_shared/scaffold-conventions.md` states.