AGENTS.md@template/stack/packages/api · git:20260805.e8316e9 · 2026-08-05 · sha256 7144e1f805fbc44d
AGENTS.md@template/stack/packages/api git:20260805.e8316e9A
Immutable. This exact content never changes and is served at /api/v1/blob/7144e1f805fbc44d.
# packages/api — `@app/api` (the tRPC router) The framework-neutral tRPC v11 router that `apps/web` SERVES (at `app/api/trpc/[trpc]/route.ts`) and `apps/mobile` consumes `import type` only. It imports NO `next/*` — the reversibility wall (`packages/api ↛ next/*`, depcruise-pinned in `scripts/rule-integrity.json`); the whole package promotes to a standalone `apps/api` by moving one `route.ts`. ## The procedure ladder (`src/trpc.ts`) Build every procedure on a rung, never on the bare `t.procedure`: - **`publicProcedure`** — open, but behind the version-skew guard. The guard is on the BASE so every rung inherits it: a major `x-client-version` mismatch, or a build below the minimum-supported-client floor, throws `CONFLICT` with machine code `version_skew` before any handler runs. Requests with no version header pass (curl, health tooling). - **`authedProcedure`** — throws `UNAUTHORIZED` when `ctx.actor === null`. This is the ONE sanctioned domain-adjacent throw; it narrows `ctx.actor` to non-null for everything downstream, so no authed handler re-checks identity and none can forget to. - **`orgProcedure`** — the acting org is an AUTHORIZATION outcome, not a transport fact, so it does NOT throw: `const gate = ctx.org; if (!gate.ok) return gate` — the same two lines in every org procedure, which makes a missing gate visible in review. EVERY procedure rides this rung, READS INCLUDED: a user in three orgs has three sets, RLS admits all of them at once, and a read with no active org would return them interleaved. It is not the isolation boundary — the RLS policies are — so a bug here yields a denial or an empty page, never a cross-tenant read. No transformer: every payload is JSON-safe by construction, so a plain `curl` sees the same bytes the typed client does. ## The envelope Procedures return `ActionOutcome<T>` from `@app/errors` on the DATA channel; a domain failure is a returned `outcomeErr(appError.X())`, NEVER a thrown `TRPCError`. Only two throws cross the wire — the auth `UNAUTHORIZED` and the skew `CONFLICT` — both transport facts a handler could not have produced (an `.input()` parse failure is the framework's `BAD_REQUEST`). The `errorFormatter` stamps `data.appCode` so clients switch on a stable code, never a message. ## Rules - A router delegates to a vertical (`@app/<slice>`); no business logic and no direct database access lives in the router. Reads go through the vertical's `./client`, writes through its server barrel. - Add or change a procedure → run `pnpm gen`: the `contracts` gate regen-diffs the committed action inventory, and the `parity` gate holds the mobile ledger to it. - `IsAny<AppRouter>` is the compile-time guard the mobile side re-asserts, so a router typed `any` (a lost import, a broken generic) reds at build rather than shipping an untyped client. - Reviewer: `security-reviewer` on any procedure that reads/writes user data; `web-security-reviewer` if the change touches the route handler's client selection.