@assembly-js/app-foundation 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -19,6 +19,13 @@ A patch release never changes what an app's build requires; a minor release may
19
19
  not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
20
20
  `bump: minor` change file.
21
21
 
22
+ ## 0.9.0
23
+ - [guidance] The build agent's auth guidance is one `## Auth` section and one `assembly-auth` skill instead of two sections and two skills that retold the same rules: about 3.1k tokens instead of 10k. A page or server action that reads one scoped record now checks it with `requireCompanyScope`, like an `[id]` route.
24
+ - [guidance] `check-token-usage` points at the `assembly-auth` skill.
25
+
26
+ ## 0.8.2
27
+ - [guidance] The build agent states its reading of an ambiguous rule about money or stored records before building it, asks before writing that reading into live records, and enforces a rule the user has stated instead of warning about it.
28
+
22
29
  ## 0.8.1
23
30
  - [guidance] The build agent keeps the session check on every API route, even when the user asks for a temporary unauthenticated test route.
24
31
 
package/CLAUDE.md CHANGED
@@ -12,7 +12,12 @@ In user-facing chat, never mention ASSEMBLY_ENV, COPILOT_ENV, or any other inter
12
12
 
13
13
  When you describe a feature or a change, describe what the user and their clients will **experience** — who can open the app, what each audience sees or can do. The mechanics that produce it are plumbing too: keep manifest keys (`surfaces`, `app.json`), file names, SDK method names, and identity fields like `clientId`/`internalUserId` out of chat unless the user asks how it works. "Clients will see only their own record" is something they can act on; "add a client surface to the manifest and branch on `identity.clientId`" is not.
14
14
 
15
- When a request implies a scope or permission change, apply it and report the product effect — don't stop to ask the user to approve the mechanics. "When a client views this, show them their own info" already means "make this client-facing," so make the change and say what happened ("clients now see just their own record; your team still sees everyone"). Ask first only on a genuine product choice the user can weigh — who should see the app, what each audience sees — phrased in those terms, never as a manifest or surface decision.
15
+ When a request implies a scope or permission change, apply it and report the product effect — don't stop to ask the user to approve the mechanics. "When a client views this, show them their own info" already means "make this client-facing," so make the change and say what happened ("clients now see just their own record; your team still sees everyone"). Ask first only on a genuine product choice the user can weigh — who should see the app, what each audience sees, what a rule about money or stored records means (below) — phrased in those terms, never as a manifest or surface decision.
16
+
17
+ **Rules about money or stored records.** "Refund 3 of the remaining meetings", "one late fee per vendor, never two": everyday words that decide what a client is charged or what a record says. If your closing summary would carry a caveat about one ("you didn't say which 3", "a vendor flagged in both rounds is still charged twice"), that gap belongs before the build, not after it.
18
+
19
+ - When the words admit more than one reading, say the one you are building in one line before you build; a rule built as app logic can be changed next turn. A write to live records — rows in the app's database, client or company records, custom fields, invoices, refunds — cannot be taken back, so when the reading decides such a write, ask first and hold the write until the user confirms.
20
+ - When the user has stated the rule, the app enforces it. A warning staff can click past, or a note beside two fields, is not an implementation of "never"; make the invalid state impossible instead.
16
21
 
17
22
  ---
18
23
 
@@ -64,8 +69,7 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
64
69
  - `assembly-workspace-labels` — workspace-customized display labels for clients/companies. The server helper comes from `@assembly-js/app-foundation/server`, the `useLabels()` hook from the repo — import them, don't rewrite them.
65
70
  - `assembly-db-transactions` — REQUIRED when a route makes more than one DB write: `db.transaction()` is unsupported on neon-http and 500s. Use `db.batch([...])` for atomic multi-writes, sequential awaits otherwise.
66
71
  - `assembly-record-writes` — REQUIRED when a form creates a record that a later save also writes, especially with nested rows (packages, add-ons, line items, blocks): create and save call one writer so create cannot drop submitted fields.
67
- - `assembly-session-token` — the bearer lifecycle and how to read identity or fetch data: `getSessionIdentity()`/`useAssembly()`, `useServerAction`/`callAction`, `authFetch`. You never handle a token yourself.
68
- - `assembly-route-auth` — REQUIRED on every `app/api/.../[id]/route.ts` handler: `requireAuth` + `requireCompanyScope` before returning or mutating data.
72
+ - `assembly-auth` — REQUIRED before writing any `app/api` route, server action or client call to the server: `requireAuth` → `requireCompanyScope` → `handleRouteError` on every `[id]` handler (GET included), `getAssemblyForRequest`, `useServerAction`/`callAction`/`authFetch`, the offline bearer. You never handle a token yourself.
69
73
  - `assembly-scheduled-work` — REQUIRED when the prompt implies anything on a schedule or while nobody has the app open (remind, daily digest, every week, follow up in N days, at a specific time): one `*/30 * * * *` Vercel cron in `vercel.json` hitting one dispatcher route, every scheduled action a row in a schedule table, `CRON_SECRET` declared in `requiredSecrets` and verified on every tick, `getOfflineAssembly()` for platform calls. Checking for due work on page load ships a schedule that never fires.
70
74
  - `assembly-route-errors` — REQUIRED in every route handler: use `handleRouteError` from `@assembly-js/app-foundation/server` in catch blocks — never return `err.message`. Validate values before they hit typed DB columns (e.g. `parseUuid` before a uuid-column lookup).
71
75
  - `assembly-api-contract` — REQUIRED whenever an `app/api` route is added, removed, or changes its request or response shape: keep `.assembly/openapi.json` (OpenAPI 3.1, `servers: [{"url": "/api"}]`, paths without the `/api` prefix) in step; cron, webhook, `assembly-*` and `privateRoutes` routes stay out.
@@ -520,7 +524,7 @@ Two things flow into the running app from Assembly:
520
524
  | | Where | Visibility |
521
525
  |---|---|---|
522
526
  | `ASSEMBLY_API_KEY` | `process.env.ASSEMBLY_API_KEY` | **Server only.** Never expose. Identifies the *app*, not the user. Used by the foundation to mint bearers — you don't touch it. |
523
- | User bearer | HttpOnly cookie, set by the middleware after the one-time `?token=` exchange | **You never handle it.** `getAssembly()` reads it to act as the current user; `getSessionIdentity()` returns the viewer the platform verified for it. See "How auth works". |
527
+ | User bearer | HttpOnly cookie, set by the middleware after the one-time `?token=` exchange | **You never handle it.** `getAssembly()` reads it to act as the current user; `getSessionIdentity()` returns the viewer the platform verified for it. See "Auth". |
524
528
 
525
529
  ---
526
530
 
@@ -910,315 +914,29 @@ export async function GET(request: NextRequest) {
910
914
  }
911
915
  ```
912
916
 
913
- ## How auth works — read this first (it's automatic, and not what you'd guess)
914
-
915
- Your app authenticates every API call with a short-lived, user-scoped **bearer token**. You never handle the raw session token and you never pass a token to anything — the foundation manages the whole lifecycle:
916
-
917
- 1. **On the first load only**, the parent opens your app at `…/?token=<session-token>`.
918
- 2. The foundation middleware (`proxy.ts`) **exchanges that session token exactly once** for a bearer: it calls `POST /v1/session` server-side and stores the returned bearer in an HttpOnly, `SameSite=None` cookie. This mint is the only time the session token is ever used.
919
- 3. Every server call your app makes uses that bearer. `getAssembly()` reads the cookie and returns an authenticated client; the platform validates the bearer on each call.
920
- 4. The bearer lasts 60 minutes. When it expires, `authFetch` and `useServerAction` **silently re-exchange** (mint a fresh bearer from the parent's current session token via `/api/assembly-reexchange`) and retry — no page reload, no code from you.
921
-
922
- Internalize these consequences — they are exactly what generated apps get wrong:
923
- - **Never read `searchParams.token`, never put `?token=` in a `<Link>`/`router.push`, never store a token.** By the time your code runs the bearer is already in the cookie; the URL token is the middleware's business, not yours. Inner pages need nothing from the URL to be authenticated.
924
- - **Never pass a token to any function.** `getAssembly()`, server actions, and route handlers all take **no** token argument.
925
- - **Never call `assemblyApi` yourself, and never authenticate on the client.** Every API call is server-side through `getAssembly()` / `getAssemblyForRequest()`.
926
-
927
- Every authenticated call your app makes is one of exactly three kinds:
928
- 1. **User bearer** — the default. `getAssembly()` acts as the current viewer. Use it for anything shown to or done for the person using the app.
929
- 2. **Installer client** — `getInstallerAssembly()`, allowed inside a user request. Use it for staff-wide reads and writes the app is entitled to on its own, including notification helpers, app storage and connectors. It acts as the installing admin, so filter anything from it before showing it to a client. A viewer cookie does not make it refuse; `getOfflineAssembly()` does.
930
- 3. **Offline bearer** — for background work with no user in the loop (cron, webhooks). Call `getOfflineAssembly()` (see "Offline bearer" below); it mints an app-scoped bearer from your API key server-side. Only needed outside a request; it refuses when a viewer is present.
931
-
932
- ### `getAssembly()` — the only auth helper you need
933
-
934
- `getAssembly` comes from `@assembly-js/app-foundation/server`, an installed dependency — import from it, never edit or reimplement it. (Its `/client` subpath is the subset a `'use client'` module may import; `/server` covers everything server-side.) Call `getAssembly()` with **no arguments** anywhere server-side (server component, server action, route handler). It reads the bearer for the current request and returns an authenticated, per-request client:
935
-
936
- ```ts
937
- import { getAssembly } from '@assembly-js/app-foundation/server';
938
-
939
- const assembly = await getAssembly(); // no token — the bearer comes from the cookie
940
- const items = await assembly.listItems({});
941
- ```
942
-
943
- `getAssembly()` is async — always `await` it. If the bearer is missing or expired it throws `TokenInvalidError`; let it propagate, and the client helpers (`useServerAction` / `authFetch`) re-exchange and retry for you. If the platform cannot be reached to verify the bearer it throws `AuthUnavailableError` (and `getSessionIdentity()` does too) — a different error on purpose: it is not an expiry, so never catch it into `null`/signed-out and never re-exchange on it; let a route return 503, and render a retry state ("Couldn't load, try again" with a retry button) wherever a server component reads identity.
944
-
945
- ### Identity — who is the viewer?
946
-
947
- Server-side, `getSessionIdentity()` returns the viewer the platform verified for the bearer (no token to pass; the check is cached briefly per server instance):
948
-
949
- ```ts
950
- import { getSessionIdentity } from '@assembly-js/app-foundation/server';
951
-
952
- const id = await getSessionIdentity();
953
- // { workspaceId, clientId?, companyId?, internalUserId? } | null
954
- if (id?.internalUserId) {
955
- // internal staff — render the staff view
956
- } else if (id?.clientId) {
957
- // client user — render their own view
958
- }
959
- ```
960
-
961
- Client-side, `useAssembly()` exposes the same identity, seeded from the server — **no token, no loading state**:
962
-
963
- ```tsx
964
- 'use client';
965
- import { useAssembly } from '@/app/components/assembly-context';
966
-
967
- export default function MyWidget() {
968
- const { workspaceId, clientId, internalUserId, isClient } = useAssembly();
969
- return <div>{isClient ? 'Client view' : 'Staff view'}</div>;
970
- }
971
- ```
972
-
973
- Branch your UI on identity **before** making data calls — it's faster and more reliable than catching a 403.
917
+ ## Auth — automatic; read the `assembly-auth` skill before writing any route, server action or client call
974
918
 
975
- ### Fetching data — server actions via `useServerAction`
919
+ The foundation runs the whole session; you write none of it:
976
920
 
977
- Put SDK calls in `'use server'` actions that take **no token** and call `getAssembly()`. From the client, run them through `useServerAction` (`app/components/use-server-action.ts`) — it manages loading/error state and **silently re-exchanges + retries once** on a 60-minute bearer expiry, so you never write refresh logic:
921
+ - The parent opens your app **once** at `…/?token=<session-token>`; the middleware (`proxy.ts`) exchanges it at `POST /v1/session` for a 60-minute, user-scoped **bearer** in an HttpOnly cookie. That mint is the only use of the session token.
922
+ - Every server call reads the bearer from the cookie. Nothing takes a token argument: not `getAssembly()`, not a server action, not a route handler.
923
+ - On expiry, `useServerAction`, `callAction` and `authFetch` silently re-exchange through `/api/assembly-reexchange` and retry. Nothing to refresh, subscribe to or reload; a "people get logged out" request needs no code.
924
+ - So: never read `searchParams.token`, never put `?token=` in a `<Link>`/`router.push`, never store or decode a token, never touch `AssemblyBridge.sessionToken.*`, never set an auth cookie, never hand-roll refresh or retry, never call `assemblyApi` or authenticate on the client.
925
+ - Data actions let `TokenInvalidError` propagate (the retry fires only if the expiry surfaces). `AuthUnavailableError` means the platform couldn't verify the bearer: not an expiry, so never catch it into `null`; render a retry state.
978
926
 
979
- ```ts
980
- // app/actions/items.ts
981
- 'use server';
982
- import { getAssembly } from '@assembly-js/app-foundation/server';
983
-
984
- export async function loadItems() {
985
- const assembly = await getAssembly(); // throws TokenInvalidError if the bearer is gone
986
- return assembly.listItems({});
987
- }
988
- ```
989
-
990
- ```tsx
991
- // app/components/my-widget.tsx
992
- 'use client';
993
- import { useServerAction } from '@/app/components/use-server-action';
994
- import { loadItems } from '@/app/actions/items';
995
-
996
- export default function MyWidget() {
997
- const { data: items, isLoading, error, reload } = useServerAction(() => loadItems(), []);
998
- // `deps` ([]) works like useEffect deps — list anything the action closes over.
999
- // Deps must be primitives (an id, a cursor, `items.length`). A derived array or
1000
- // object is a new identity every render and spins the page in a fetch loop.
1001
- }
1002
- ```
1003
-
1004
- For an imperative one-off (button handler, mutation), use `callAction` from `useAssembly()` — same silent re-exchange + retry, no hook: `await callAction(() => updateItem(values))`.
1005
-
1006
- **Two hard rules** (this is exactly what generated apps get wrong):
1007
- - **Actions take no token.** Never accept, read, or pass a token — `getAssembly()` resolves the bearer itself. Never touch `AssemblyBridge.sessionToken.*` in a component or copy anything into your own `useState`/`useRef`.
1008
- - **Data actions must let `TokenInvalidError` propagate** — never `try/catch` it into `null`/empty. The auto-retry only fires if the action surfaces the expiry. And never hand-roll refresh/retry; `useServerAction`/`callAction` already do it.
1009
-
1010
- ### Server action or route handler?
1011
-
1012
- A client-to-server call is a server action by default. Its argument is a TypeScript parameter, so a payload that does not match the signature fails `tsc` in your inner loop. A route handler's body is an untyped wire contract — `JSON.stringify` on one side, `request.json()` on the other — and nothing checks that the two agree until a user sends the first request, which you cannot do from the sandbox. A mismatch `tsc` would catch in an action ships silently through a route and 400s for every user.
1013
-
1014
- Write a route handler only when the caller needs a raw `Response` — streamed bytes, a file download, a webhook or cron entry point — or when the caller is not this app's own client component. When you do, define the body schema in a shared `lib/` module with no server-only imports, and type the client's payload with `z.input<typeof schema>` so the compiler checks the wire shape:
1015
-
1016
- ```ts
1017
- // lib/reply-contract.ts — imported by both the route and the component (`bun add zod` if it is not installed yet)
1018
- import { z } from 'zod';
1019
- export const replyBody = z.object({ parentId: z.string().uuid().nullish(), text: z.string().min(1) });
1020
- export type ReplyBody = z.input<typeof replyBody>;
1021
-
1022
- // app/api/replies/route.ts: const parsed = replyBody.safeParse(await request.json());
1023
- // component: const body: ReplyBody = { parentId, text }; // tsc rejects what the route would 400
1024
- ```
1025
-
1026
- ### Client → server via route handlers — `authFetch`
1027
-
1028
- For the route-handler calls that remain, use `authFetch` from `app/components/lib-client.ts`. The bearer rides the cookie automatically; `authFetch` adds the required CSRF header and re-exchanges + retries once on a bearer expiry:
1029
-
1030
- ```tsx
1031
- 'use client';
1032
- import { authFetch } from '@/app/components/lib-client';
1033
-
1034
- async function saveFlavor(clientId: string, flavor: string) {
1035
- const res = await authFetch(`/api/clients/${clientId}/flavor`, {
1036
- method: 'PATCH',
1037
- headers: { 'Content-Type': 'application/json' },
1038
- body: JSON.stringify({ flavor }),
1039
- });
1040
- if (!res.ok) throw new Error(await res.text());
1041
- return res.json();
1042
- }
1043
- ```
927
+ **Which client.** `getAssembly` and its siblings come from `@assembly-js/app-foundation/server` (a `'use client'` module imports only from `/client`); import them, never edit or reimplement them.
1044
928
 
1045
- Every state-changing request (POST/PUT/PATCH/DELETE) **must** go through `authFetch` (or a Next server action) — a plain `fetch` is rejected with 403 (CSRF). Never put a token or the bearer in a URL or a manual header; the cookie carries it.
929
+ - `getAssembly()` — the viewer, in server components and server actions. The default for anything shown to or done for the person using the app.
930
+ - `getAssemblyForRequest(request)` — the viewer, in a route handler; returns the client or a 401 to return as is.
931
+ - `getInstallerAssembly()` — the installing admin, inside a user request, for staff-wide reads and writes the app is entitled to on its own (notification helpers, app storage, connectors). Filter what it returns before showing it to a client.
932
+ - `getOfflineAssembly()` — no viewer at all (cron, webhooks): an app-scoped bearer minted from `ASSEMBLY_API_KEY`. It refuses inside a viewer request.
1046
933
 
1047
- In current apps `proxy.ts` lets a request that carries no cookies skip the CSRF check, so an inbound third-party call (a webhook) needs no proxy change; an older `proxy.ts` without that rule still answers 403 to a header-less POST. Authenticate the caller inside the route (signature or shared secret), fail closed when its secret is unset, and never add an unauthenticated test endpoint.
934
+ **Identity.** Branch on who the viewer is before making data calls, never by catching a 403: server-side `const id = await getSessionIdentity()` (`{ workspaceId, internalUserId?, clientId?, companyId? } | null`), client-side `useAssembly()` (`isClient`, `internalUserId`, `clientId`, seeded from the server, no loading state). A client may read only its own record and its active company.
1048
935
 
1049
- ### Route handlers — `getAssemblyForRequest`
1050
-
1051
- ```ts
1052
- // app/api/clients/[clientId]/flavor/route.ts
1053
- import { NextRequest, NextResponse } from 'next/server';
1054
- import { getAssemblyForRequest } from '@assembly-js/app-foundation/server';
1055
-
1056
- export async function PATCH(
1057
- request: NextRequest,
1058
- { params }: { params: Promise<{ clientId: string }> },
1059
- ) {
1060
- const { clientId } = await params;
1061
- const assembly = await getAssemblyForRequest(request);
1062
- if (assembly instanceof NextResponse) return assembly; // 401 → authFetch re-exchanges
1063
-
1064
- const { flavor } = await request.json();
1065
- await assembly.updateClient({
1066
- id: clientId,
1067
- requestBody: {
1068
- customFields: { pizza_flavor: flavor },
1069
- },
1070
- });
1071
- return NextResponse.json({ ok: true });
1072
- }
1073
- ```
1074
-
1075
- `getAssemblyForRequest(req)` returns a bearer-authenticated client, or a 401 `NextResponse` (carrying the `x-assembly-token-expired` marker) when the bearer is missing/expired — just return it, and `authFetch` re-exchanges and retries. For an identity-only check use `requireAuth(req)` (below).
1076
-
1077
- **Auth is entirely cookie-borne: no `?token=`, no `x-assembly-token` header, no token proxy, no `TokenLink`/`useWithToken`, no client-side token at all.** The middleware put the bearer in the cookie; `getAssembly()` / `getAssemblyForRequest()` read it. That's the whole story.
936
+ **Scoped records.** Anything that loads one record by id for a client — a route handler, a page, a server action — checks it with `requireCompanyScope(identity, { companyId: row.companyId, clientId: row.clientId }, assembly)` and returns or renders "not found" on a non-null result. Never compare company or client ids by hand: the helper handles multi-company clients and client-owned rows. Every `app/api/.../[id]/route.ts` handler, GET included, runs `requireAuth` → `requireCompanyScope` → `handleRouteError`; the recipe is in `assembly-auth`.
1078
937
 
1079
938
  **Never add a route that skips the session check, and never loosen one.** Every handler under `app/api` that a viewer calls uses `requireAuth(…)` or `getAssemblyForRequest(…)`; a cron or webhook route has no viewer and verifies its own secret instead (below). The rule covers a route gated on `NODE_ENV`, a `DEBUG` or `SKIP_AUTH` flag, a secret path or `localhost`, and a route you mean to delete later: every preview deployment is reachable by anyone with its URL, so a dev-only route is a public one. It holds when the user asks for it ("just for now", "so I can test it"). You cannot sign in as staff or as a client from the sandbox, and a test route cannot make that possible either; say that you can't verify the page end to end from here, keep the guard, and test the handler's logic by calling its function with a plain argument. Never build a session, token or cookie yourself to get past a check.
1080
939
 
1081
- ### Offline bearer — background work with no user (cron, webhooks)
1082
-
1083
- **Anything on a schedule — a reminder, a digest, a recurring send — must first read the `assembly-scheduled-work` skill.** The route below only ever runs if `vercel.json` declares a cron for it and the route verifies `CRON_SECRET`; the offline bearer is the auth half, the skill is the scheduling half.
1084
-
1085
- Everything above resolves the **viewer's** bearer from the request cookie. Some code runs with **no user in the loop and no cookie**: a scheduled job, or an incoming third-party webhook. There, call `getOfflineAssembly()` — it mints a short-lived, **app-scoped** bearer from your app API key (`ASSEMBLY_API_KEY`) via `POST /v1/token` and returns a bearer-only client, exactly like the user path but authenticated as the app rather than a viewer.
1086
-
1087
- ```ts
1088
- // app/api/cron/digest/route.ts — Vercel cron or a webhook handler
1089
- import { getOfflineAssembly, listAll } from '@assembly-js/app-foundation/server';
1090
-
1091
- export async function GET() {
1092
- const assembly = await getOfflineAssembly(); // app-scoped, no cookie needed
1093
- // Background work must cover everyone — walk every page (see Pagination).
1094
- const clients = await listAll((nextToken) =>
1095
- assembly.listClients({ nextToken, limit: 100 }),
1096
- );
1097
- // …do the background work (send a digest, reconcile state, etc.)…
1098
- return Response.json({ ok: true });
1099
- }
1100
- ```
1101
-
1102
- Rules for the offline path:
1103
- - **Use it ONLY when there is genuinely no user.** Any request a person triggered — a page, a server action, an `authFetch` route — uses `getAssembly()` / `getAssemblyForRequest()` so the call acts as that viewer. Reaching for the offline bearer inside a user request bypasses the viewer's scope.
1104
- - **It throws on failure** (missing `ASSEMBLY_API_KEY`, or a failed mint) rather than returning null — there is no client to re-exchange in the background, so a missing credential must be loud. Let it propagate; don't swallow it into a partial run.
1105
- - **`getSessionIdentity()` returns `null`** here — the bearer is the app, not a person. Anything that needs a user id (e.g. a notification `senderId`) must be chosen explicitly (a system/admin internal user), not read from identity.
1106
- - Still bearer-only: no `?token=`, no `x-assembly-token`, no X-API-Key on data calls. The app key is used solely to mint the bearer.
1107
-
1108
- ### Auth guards for `[id]`-nested routes — `requireAuth` and `requireCompanyScope`
1109
-
1110
- Two pre-built helpers guard API routes that operate on company-scoped resources:
1111
-
1112
- ```ts
1113
- import { requireAuth, requireCompanyScope } from '@assembly-js/app-foundation/server';
1114
- ```
1115
-
1116
- **`requireAuth(req)`** verifies the bearer with the platform and returns the identity from the platform's verification response. Returns a 401 `NextResponse` with a `{ code: 'token_expired' }` body **and** `x-assembly-token-expired: 1` header when the bearer is missing, expired or not accepted — `authFetch` detects this and re-exchanges + retries automatically. When the platform cannot be reached to verify, it returns a 503 with `{ code: 'auth_unavailable' }` and **no** expiry marker, so nothing re-exchanges; the 503 reaches the app, which should render a retry state or let the user retry.
1117
-
1118
- **`requireCompanyScope(identity, resource, assembly?)`** asserts the viewer can access a resource scoped to a company **or to a client**. Internal users always pass (client-vs-staff isolation only — not Client Access; filter app-owned data for limited internal users per `assembly-internal-users`). For a company-scoped resource, client access is checked against `identity.companyId`; when that is absent (a multi-company client not loaded in a CRM tab), pass the bearer client from `getAssemblyForRequest` as the third argument and the helper fetches the client's full `companyIds` list to verify membership. For a **client-scoped resource** (`companyId` null), pass the resource's `clientId` too — `{ companyId: item.companyId, clientId: item.clientId }` — so the owning client passes; without it every client gets a 404, including the one the record is assigned to. Returns `null` on success, or a 403/404 `NextResponse` on mismatch. A 403 from `requireCompanyScope` is a permanent authorization failure — a re-exchange will not fix cross-tenant access. When a row carries **both** scope columns the helper requires both to match, so a client-owned row is never reachable by a sibling client in the same company — see `assembly-route-auth`.
1119
-
1120
- ```ts
1121
- // app/api/items/[id]/route.ts
1122
- import { NextRequest, NextResponse } from 'next/server';
1123
- import {
1124
- requireAuth,
1125
- requireCompanyScope,
1126
- getAssemblyForRequest,
1127
- handleRouteError,
1128
- parseUuid,
1129
- } from '@assembly-js/app-foundation/server';
1130
- import { db } from '@/lib/db';
1131
- import { items } from '@/lib/schema';
1132
- import { eq } from 'drizzle-orm';
1133
-
1134
- export async function GET(
1135
- request: NextRequest,
1136
- { params }: { params: Promise<{ id: string }> },
1137
- ) {
1138
- const auth = await requireAuth(request);
1139
- if (auth instanceof NextResponse) return auth;
1140
-
1141
- const { id: rawId } = await params;
1142
- // items.id is uuid — validate at the lookup, not because the segment is named [id]
1143
- const id = parseUuid(rawId);
1144
- if (id instanceof NextResponse) return id;
1145
-
1146
- try {
1147
- const item = await db.query.items.findFirst({ where: eq(items.id, id) });
1148
- if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 });
1149
-
1150
- // Only needed for the multi-company membership check; omit otherwise.
1151
- const assembly = await getAssemblyForRequest(request);
1152
- if (assembly instanceof NextResponse) return assembly;
1153
- // Pass both scope columns — clientId is what lets a client-scoped row
1154
- // (companyId null) pass for its owning client.
1155
- const scopeError = await requireCompanyScope(
1156
- auth,
1157
- { companyId: item.companyId, clientId: item.clientId },
1158
- assembly,
1159
- );
1160
- if (scopeError) return scopeError;
1161
-
1162
- return NextResponse.json(item);
1163
- } catch (err) {
1164
- return handleRouteError(err);
1165
- }
1166
- }
1167
- ```
1168
-
1169
- ---
1170
-
1171
- ## Session & bearer lifecycle (under the hood — you write none of it)
1172
-
1173
- You don't manage sessions; the foundation does. This is the flow so you understand where auth comes from:
1174
-
1175
- - The parent opens your app with `?token=<session-token>` **once** (5-minute TTL, encrypted, rotated by the parent).
1176
- - The middleware exchanges it at `POST /v1/session` for a **60-minute user-scoped bearer** and stores it in an HttpOnly cookie. The session token is used only for this mint.
1177
- - Every server call uses the bearer; `getAssembly()` reads the cookie.
1178
- - On bearer expiry, `authFetch` / `useServerAction` post the parent's current session token to `/api/assembly-reexchange`, which mints a fresh bearer and updates the cookie — then retries. Silent; no reload.
1179
-
1180
- So there is nothing to refresh, subscribe to, or thread. Don't touch `AssemblyBridge.sessionToken.*`, don't read `searchParams.token`, don't decode anything client-side.
1181
-
1182
- ### Identify the viewer
1183
-
1184
- Use identity to branch your UI *before* making data calls — don't infer the viewer by catching a 403 (slow, and any unrelated 403 misclassifies).
1185
-
1186
- Server-side (`getSessionIdentity()` returns the platform-verified viewer; no token to pass):
1187
-
1188
- ```ts
1189
- import { getSessionIdentity } from '@assembly-js/app-foundation/server';
1190
-
1191
- const id = await getSessionIdentity();
1192
- // { workspaceId, internalUserId?, clientId?, companyId? } | null
1193
- if (id?.internalUserId) {
1194
- // internal staff — render the staff view (lists, tables, admin actions)
1195
- } else if (id?.clientId) {
1196
- // client user — render their own view (profile / settings)
1197
- } else {
1198
- // no session — render an unauthenticated state
1199
- }
1200
- ```
1201
-
1202
- Client-side, the same fields come from `useAssembly()` (seeded from the server, no token). If you also need the full record (a client may read only its own record and its active company, `id.companyId`; any other company id is refused):
1203
-
1204
- ```ts
1205
- const assembly = await getAssembly();
1206
- const id = await getSessionIdentity();
1207
-
1208
- if (id?.internalUserId) {
1209
- const me = await assembly.retrieveInternalUser({ id: id.internalUserId });
1210
- return { type: 'internal' as const, user: me };
1211
- }
1212
- if (id?.clientId) {
1213
- const me = await assembly.retrieveClient({ id: id.clientId });
1214
- const company = id.companyId
1215
- ? await assembly.retrieveCompany({ id: id.companyId })
1216
- : null;
1217
- return { type: 'client' as const, user: me, company };
1218
- }
1219
- return { type: 'app' as const };
1220
- ```
1221
-
1222
940
  ---
1223
941
 
1224
942
  ## Server-side SDK: `@assembly-js/node-sdk`
@@ -1462,7 +1180,7 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
1462
1180
 
1463
1181
  ### Session token — the foundation uses this, not you
1464
1182
 
1465
- `AssemblyBridge.sessionToken.*` exists, but **your app never calls it.** The foundation reads it internally to mint/re-exchange the bearer (see "Session & bearer lifecycle"). Authenticate with `getAssembly()` / `getAssemblyForRequest()`; identify the viewer with `getSessionIdentity()` / `useAssembly()`.
1183
+ `AssemblyBridge.sessionToken.*` exists, but **your app never calls it.** The foundation reads it internally to mint/re-exchange the bearer (see "Auth"). Authenticate with `getAssembly()` / `getAssemblyForRequest()`; identify the viewer with `getSessionIdentity()` / `useAssembly()`.
1466
1184
 
1467
1185
  ---
1468
1186
 
@@ -154,6 +154,6 @@ if (violations.length > 0) {
154
154
  console.error('entry page, should read searchParams.token. getAssembly() and');
155
155
  console.error('getSessionIdentity() read the bearer cookie; fetch per-user data via');
156
156
  console.error('server actions (useServerAction) or authFetch route handlers.');
157
- console.error('See CLAUDE.md and the assembly-session-token skill.');
157
+ console.error('See CLAUDE.md and the assembly-auth skill.');
158
158
  process.exit(1);
159
159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,135 @@
1
+ ---
2
+ name: assembly-auth
3
+ description: Use before writing an app/api route (every [id] route, GET included), a server action, a client call to the server or background work, or touching auth, session, token or identity. requireAuth → requireCompanyScope → handleRouteError, getAssemblyForRequest, useServerAction, authFetch.
4
+ ---
5
+
6
+ `CLAUDE.md` → **Auth** has the lifecycle and the rules. This is the how-to. Everything below comes from `@assembly-js/app-foundation/server` unless it says otherwise.
7
+
8
+ Use `getAssembly()` for anything shown to or done for the person using the app, and `getInstallerAssembly()` for staff-wide reads and writes the app is entitled to on its own, including notification helpers.
9
+
10
+ ## Client → server: a server action, by default
11
+
12
+ A server action's argument is a TypeScript parameter, so `tsc` checks the payload. A route handler's body is an untyped wire contract that nothing checks until a user sends the first request, which you cannot do from the sandbox. Write a route handler only when the caller needs a raw `Response` (streamed bytes, a file download, a webhook or cron entry point) or isn't this app's own client component.
13
+
14
+ ```ts
15
+ // app/actions/projects.ts
16
+ 'use server';
17
+ import { getAssembly } from '@assembly-js/app-foundation/server';
18
+
19
+ export async function loadClients() {
20
+ const assembly = await getAssembly(); // no token; throws TokenInvalidError on expiry, let it propagate
21
+ return assembly.listClients({});
22
+ }
23
+ ```
24
+
25
+ ```tsx
26
+ 'use client';
27
+ import { useServerAction } from '@/app/components/use-server-action';
28
+ import { useAssembly } from '@/app/components/assembly-context';
29
+
30
+ const { data, isLoading, error, reload } = useServerAction(() => loadClients(), []);
31
+ const { callAction } = useAssembly();
32
+ const onArchive = () => callAction(() => archiveProject(id)); // a button or mutation
33
+ ```
34
+
35
+ `deps` work like `useEffect` deps and must be primitives (an id, a cursor, `items.length`): a derived array or object is a new identity every render and spins the page in a fetch loop. Never `try/catch` an SDK call into `null` inside a data action; the silent re-exchange fires only if the expiry surfaces.
36
+
37
+ When a route handler is the right tool, call it with `authFetch` from `@/app/components/lib-client`. It adds the CSRF header and re-exchanges and retries once on expiry; a plain `fetch` to a state-changing route gets a 403. Never put a token or the bearer in a URL or header. Define the body schema in a shared `lib/` module with no server-only imports, and type the client's payload with `z.input<typeof schema>` so the compiler checks the wire shape.
38
+
39
+ An inbound third-party call (a webhook) carries no cookies, and current `proxy.ts` lets a cookie-less request skip the CSRF check, so it needs no proxy change; an older `proxy.ts` still answers 403 to a header-less POST. Authenticate the caller inside the route (signature or shared secret) and fail closed when its secret is unset.
40
+
41
+ ## Route handlers
42
+
43
+ `requireAuth(request)` verifies the bearer and returns the viewer's identity. `getAssemblyForRequest(request)` returns the viewer's SDK client. Each returns a `NextResponse` instead when it can't: a 401 carrying `x-assembly-token-expired` (so `authFetch` re-exchanges and retries) or a 503 `auth_unavailable` when the platform can't be reached (nothing re-exchanges). Return it as is. Never use `getAssembly()` in a route handler.
44
+
45
+ A route that must stay human-only passes `{ allow: ['internal', 'client'] }` to `requireAuth` and `requireCompanyScope`. With no option an app caller is allowed too; an app caller on a route that doesn't list `'app'` gets 403. When you branch on `viewer.kind`, keep a default branch: the set of kinds can grow.
46
+
47
+ Routes listed in `privateRoutes` in `.assembly/app.json` (globs such as `/internal/**`, matched against the route template; `*` stays within a segment, `**` crosses segments) are not offered to other apps.
48
+
49
+ ### Every `[id]` route: `requireAuth` → `requireCompanyScope` → `handleRouteError`
50
+
51
+ Every `app/api/.../[id]/route.ts` handler, **GET included** and at every nesting depth, runs this sequence before returning or mutating data. Skipping it on a GET is a cross-tenant IDOR: a user in company A reads company B's records by changing the id in the URL.
52
+
53
+ ```ts
54
+ // app/api/projects/[id]/route.ts
55
+ import { NextRequest, NextResponse } from 'next/server';
56
+ import { eq } from 'drizzle-orm';
57
+ import {
58
+ getAssemblyForRequest,
59
+ handleRouteError,
60
+ parseUuid,
61
+ requireAuth,
62
+ requireCompanyScope,
63
+ } from '@assembly-js/app-foundation/server';
64
+ import { db } from '@/lib/db';
65
+ import { projects } from '@/lib/schema';
66
+
67
+ export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
68
+ const auth = await requireAuth(request);
69
+ if (auth instanceof NextResponse) return auth;
70
+
71
+ const id = parseUuid((await params).id); // only because projects.id is a uuid column
72
+ if (id instanceof NextResponse) return id;
73
+
74
+ try {
75
+ const project = await db.query.projects.findFirst({ where: eq(projects.id, id) });
76
+ if (!project) return NextResponse.json({ error: 'Not found' }, { status: 404 });
77
+
78
+ const assembly = await getAssemblyForRequest(request);
79
+ if (assembly instanceof NextResponse) return assembly;
80
+ const scopeError = await requireCompanyScope(
81
+ auth,
82
+ { companyId: project.companyId, clientId: project.clientId },
83
+ assembly,
84
+ );
85
+ if (scopeError) return scopeError;
86
+
87
+ // Never spread the full body: a caller could send companyId or id and reparent the row.
88
+ const { status } = await request.json();
89
+ await db.update(projects).set({ status }).where(eq(projects.id, id));
90
+ return NextResponse.json({ ok: true });
91
+ } catch (err) {
92
+ return handleRouteError(err, 'Update failed');
93
+ }
94
+ }
95
+ ```
96
+
97
+ - **GET is the same** without the body. A sub-resource (`/projects/[id]/notes/[noteId]`) loads the **sub-resource** and scopes on its own `companyId` and `clientId`, never on the parent's.
98
+ - Do not treat every `[id]` param as a UUID: only validate as UUID when the next step is a uuid-column lookup (`parseUuid`). Catch blocks use `handleRouteError` and never return `err.message` (`assembly-route-errors`).
99
+
100
+ ### How `requireCompanyScope(identity, resource, assembly?)` decides
101
+
102
+ - Internal users always pass (client-vs-staff isolation only, **not** Client Access: limited internal users still reach another company's row unless you filter app-owned queries yourself; see `assembly-internal-users`).
103
+ - A company-scoped row (`companyId` set) is checked against `identity.companyId`; when that is absent (a multi-company client), the `assembly` argument lets the helper fetch the client's `companyIds`.
104
+ - A client-scoped row (`companyId` null) passes only for its `clientId`. A row carrying **both** columns requires both, so a sibling client in the same company gets a 404; add no ownership check of your own.
105
+ - Returns `null`, or a 403/404 `NextResponse`. A 403 is permanent; a re-exchange will not fix it.
106
+
107
+ **Always select and pass both scope columns.** Without `clientId`, a client-scoped row 404s for the very client it belongs to; give tables both columns (`assembly-clients-companies`).
108
+
109
+ ### A page or action that reads one scoped record
110
+
111
+ The same check, with identity from `getSessionIdentity()` and the client from `getAssembly()`:
112
+
113
+ ```tsx
114
+ // app/projects/[id]/page.tsx
115
+ const identity = await getSessionIdentity();
116
+ if (!identity) notFound();
117
+ const project = await db.query.projects.findFirst({ where: eq(projects.id, id) });
118
+ if (!project) notFound();
119
+ if (await requireCompanyScope(identity, { companyId: project.companyId, clientId: project.clientId }, await getAssembly())) notFound();
120
+ ```
121
+
122
+ A list a client sees filters on the same columns in the query (`assembly-clients-companies`).
123
+
124
+ ## Background work: the offline bearer
125
+
126
+ A cron job or webhook has no viewer and no cookie. Call `getOfflineAssembly()`: it mints an app-scoped bearer from `ASSEMBLY_API_KEY`. Anything on a schedule reads `assembly-scheduled-work` first; that skill has the cron route and its `CRON_SECRET` check.
127
+
128
+ ```ts
129
+ const assembly = await getOfflineAssembly();
130
+ const clients = await listAll((nextToken) => assembly.listClients({ nextToken, limit: 100 }));
131
+ ```
132
+
133
+ - Only with genuinely no user. Inside a request a person triggered it bypasses the viewer's scope, and it refuses when a viewer is present.
134
+ - It throws when the key is missing or the mint fails. Let it propagate: there is no client to re-exchange in the background.
135
+ - `getSessionIdentity()` is `null` here, so a user id (a notification `senderId`) must be chosen explicitly.
@@ -19,7 +19,7 @@ if (identity?.clientId) {
19
19
  }
20
20
  ```
21
21
 
22
- Never call `assembly.getTokenPayload()` for this — see `assembly-session-token`.
22
+ Never call `assembly.getTokenPayload()` for this — see `assembly-auth`.
23
23
 
24
24
  (`internalUserId` is set instead for staff — see `assembly-internal-users` skill.)
25
25
 
@@ -14,7 +14,7 @@ const identity = await getSessionIdentity();
14
14
  // → { workspaceId, clientId?, companyId?, internalUserId? } | null
15
15
  ```
16
16
 
17
- Read identity with `getSessionIdentity()` (server) or `useAssembly()` (client) — see `assembly-session-token`. Never call `assembly.getTokenPayload()` in app code. The identity is the one the platform verified for the bearer, so `clientId` and `companyId` can be trusted for scoping; if verification is unavailable, `getSessionIdentity()` throws `AuthUnavailableError` — render a retry state rather than treating the viewer as signed out.
17
+ Read identity with `getSessionIdentity()` (server) or `useAssembly()` (client) — see `assembly-auth`. Never call `assembly.getTokenPayload()` in app code. The identity is the one the platform verified for the bearer, so `clientId` and `companyId` can be trusted for scoping; if verification is unavailable, `getSessionIdentity()` throws `AuthUnavailableError` — render a retry state rather than treating the viewer as signed out.
18
18
 
19
19
  **A client viewer always carries both ids.** `identity.clientId` is who they are; `identity.companyId` is the company they currently have active in the portal switcher. The platform guarantees the active company is populated for a client session, so **treat a client viewer with no `companyId` as an error, not as a case to fall back on** — do not widen the query to the client's other companies, and do not substitute `companyIds[0]`.
20
20
 
@@ -112,7 +112,7 @@ That reads as: this client's rows **within the active company**, plus the compan
112
112
 
113
113
  **On writes, validate the target company.** Before storing a row under a `companyId` the request supplied rather than the viewer's own, confirm it is actually one of the client's: `client.companyIds.includes(companyId)`. Otherwise a client can write into a company they don't belong to.
114
114
 
115
- **On `[id]` route handlers, pass both scope columns to `requireCompanyScope`.** It authorizes the intersection, so a row that also carries a `clientId` is reachable only by that client — never by a sibling in the same company. See `assembly-route-auth`.
115
+ **On `[id]` route handlers, pass both scope columns to `requireCompanyScope`.** It authorizes the intersection, so a row that also carries a `clientId` is reachable only by that client — never by a sibling in the same company. See `assembly-auth`.
116
116
 
117
117
  ## Pattern 3 — the placeholder pitfall
118
118
 
@@ -138,6 +138,5 @@ You cannot infer "this app should be company-scoped" (or "this client has a real
138
138
 
139
139
  - `assembly-companies` — company CRUD, placeholder companies (excluded from `listCompanies` by default for these apps).
140
140
  - `assembly-client-users` — identifying the current client viewer and client identity in general.
141
- - `assembly-session-token` — reading the viewer with `getSessionIdentity()` / `useAssembly()`.
142
- - `assembly-route-auth` — `requireCompanyScope` for `[id]` route handlers; pass both scope columns there too.
141
+ - `assembly-auth` — reading the viewer with `getSessionIdentity()` / `useAssembly()`, and `requireCompanyScope` for `[id]` route handlers; pass both scope columns there too.
143
142
  - `assembly-user-company-selector` — pre-built `UserCompanySelector` component for picker UIs; its `Entity` type still accepts a singular `companyId` for grouping purposes, which is fine for the component API but is not a substitute for `companyIds[]` in scope/storage decisions.
@@ -14,7 +14,7 @@ const identity = await getSessionIdentity();
14
14
  const isInternal = !!identity?.internalUserId;
15
15
  ```
16
16
 
17
- Never call `assembly.getTokenPayload()` for this — see `assembly-session-token`.
17
+ Never call `assembly.getTokenPayload()` for this — see `assembly-auth`.
18
18
 
19
19
  If `internalUserId` is set, the current viewer is staff. (`clientId` is set for client viewers — see `assembly-client-users` skill.)
20
20
 
@@ -82,7 +82,7 @@ everything rather than falling back to full access. Do not catch and widen that
82
82
  ### What to scope
83
83
 
84
84
  - **SDK lists and pickers** — `listClients`, `listCompanies`, and any UI built from them (tables, `UserCompanySelector` options) must only include clients/companies the viewer can access. Filter each page after fetch, or skip rows that fail `canAccessClient` / `canAccessCompanies`. Do not assume the platform API filtered the list for you.
85
- - **App-owned data** — queries against your Postgres tables must apply the same rule: when the viewer is limited, restrict rows to `companyId IN companyAccessList` (and for client-owned rows, ensure the client is accessible). `requireCompanyScope` lets every internal user through — it enforces client-vs-staff isolation, not Client Access. See `assembly-route-auth` and `assembly-clients-companies`.
85
+ - **App-owned data** — queries against your Postgres tables must apply the same rule: when the viewer is limited, restrict rows to `companyId IN companyAccessList` (and for client-owned rows, ensure the client is accessible). `requireCompanyScope` lets every internal user through — it enforces client-vs-staff isolation, not Client Access. See `assembly-auth` and `assembly-clients-companies`.
86
86
  - **CRM-tab context** — when the token already carries a `clientId` or `companyId`, the viewer was routed to a record they can access; scope to that tab as usual. The global Dashboard main view still needs the filters above.
87
87
  - **Fan-out to other internal users** (notifications, assignments) — filter recipients by their access to the relevant client/company.
88
88
 
@@ -122,4 +122,4 @@ Render an explicit, honest empty state and stop — "The intake form hasn't been
122
122
  - **List responses are paginated envelopes**, and `data` is nullable: destructure with a default and still index defensively (`const { data: forms = [] } = …; forms?.[0]`).
123
123
  - **Narrow before you index.** Taking `[0]` off an unfiltered list is the same guess as a hardcoded id. Filter by `membershipType` (and `companyId` for a client in multiple companies); if two candidates genuinely remain, surface the choice rather than picking one.
124
124
  - **Creating a channel is not a substitute for finding one.** `createMessageChannel` requires a `membershipType`, and its type is a string enum the SDK does not re-export from its package root — you cannot satisfy it with a plain `'individual'` string. Work with the channels the workspace already has; check the SDK types first if you genuinely need to create one.
125
- - **Ids from the app bridge or a route param** are runtime values, not literals — those are fine to pass straight to `retrieve*`, after the usual auth and scope checks (see `assembly-route-auth`).
125
+ - **Ids from the app bridge or a route param** are runtime values, not literals — those are fine to pass straight to `retrieve*`, after the usual auth and scope checks (see `assembly-auth`).
@@ -1,198 +0,0 @@
1
- ---
2
- name: assembly-route-auth
3
- description: Use for every app/api/.../[id]/route.ts handler — GET and write alike. Every handler must call requireAuth then requireCompanyScope from @assembly-js/app-foundation/server before returning or mutating data, at parity with the page-level ownership guard.
4
- ---
5
-
6
- Every `app/api/.../[id]/route.ts` handler — **GET included** — must call `requireAuth` then `requireCompanyScope` (from `@assembly-js/app-foundation/server`) before returning or mutating data. Skipping either guard on a GET handler is a cross-tenant IDOR: an authenticated user in company A can read company B's records by changing the id in the URL.
7
-
8
- Routes listed in the `privateRoutes` array of `.assembly/app.json` (globs such as `/internal/**`, matched against the route template, not the file path; `*` stays within a segment, `**` crosses segments) are not offered to other apps.
9
-
10
- Use `getAssembly()` for anything shown to or done for the person using the app, and `getInstallerAssembly()` for staff-wide reads and writes the app is entitled to on its own, including notification helpers.
11
-
12
- A route that must stay human-only passes `{ allow: ['internal', 'client'] }` to `requireAuth` and `requireCompanyScope`. With no option, an app caller is allowed too; an app caller on a route that does not list `'app'` gets 403. When you branch on `viewer.kind`, keep a default branch — the set of kinds can grow.
13
-
14
- ```ts
15
- import { requireAuth, requireCompanyScope, handleRouteError } from '@assembly-js/app-foundation/server';
16
- ```
17
-
18
- ## How the helpers work
19
-
20
- **`requireAuth(req)`** verifies the bearer (read from the request cookie — no `x-assembly-token` header) with the platform and returns the identity from the platform's verification response. Returns a 401 `NextResponse` (with a `{ code: 'token_expired' }` body and an `x-assembly-token-expired: 1` header) when the bearer is missing, expired or not accepted — `authFetch` detects this and re-exchanges + retries automatically. When the platform cannot be reached to verify, it returns a 503 with `{ code: 'auth_unavailable' }` and no expiry marker: return it as is. The 503 reaches the app, which should render a retry state or let the user retry; it never triggers a re-exchange.
21
-
22
- **`requireCompanyScope(identity, resource, assembly?)`** asserts the viewer can access a resource scoped to a company **or to a client**:
23
-
24
- - Internal users always pass (client-vs-staff isolation only — **not** Client Access; limited internal users can still read another company's row unless you filter app-owned queries and lists yourself; see `assembly-internal-users`).
25
- - For a **company-scoped** resource (`companyId` set), client access is checked against `identity.companyId`; when that is absent (a multi-company client not loaded in a CRM tab), pass the bearer client from `getAssemblyForRequest` as the third argument and the helper fetches the client's full `companyIds` list to verify membership.
26
- - For a **client-scoped** resource (`companyId` null), the helper matches `resource.clientId` against `identity.clientId`.
27
- - When a row carries **both** columns the helper requires both to match, so the company leg alone never admits a sibling client.
28
- - Returns `null` on success, or a 403/404 `NextResponse` on mismatch. A 403 is a permanent authorization failure — a re-exchange will not fix cross-tenant access.
29
-
30
- The `resource` argument is `{ companyId: string | null; clientId?: string | null }` — the same shape you already loaded from the database. **Always select and pass both columns.** Omitting `clientId` makes every client-scoped record 404 for the very client it is assigned to, and a table with no `clientId` column cannot express client scope at all — so give records both a `companyId` and a `clientId` column in the schema, even when only one is populated for a given row (see `assembly-clients-companies`).
31
-
32
- ### Client-owned rows are covered by the guard
33
-
34
- A **client-owned row** — one carrying both a `companyId` and a `clientId`, the shape `assembly-clients-companies` prescribes — is authorized on the intersection: a sibling client in the same company gets a 404. Pass both columns and add no ownership check of your own. Internal users still pass, and a company-wide row (`clientId` null) is reachable by every client in the company, as it should be.
35
-
36
- For safe catch blocks and validating values before typed DB lookups (e.g. `parseUuid` before a uuid-column query), see `assembly-route-errors`. Do not treat every `[id]` route param as a UUID — only validate as UUID when the next step is a uuid-column lookup.
37
-
38
- ## GET example
39
-
40
- ```ts
41
- // app/api/items/[id]/route.ts
42
- import { NextRequest, NextResponse } from 'next/server';
43
- import {
44
- requireAuth,
45
- requireCompanyScope,
46
- getAssemblyForRequest,
47
- handleRouteError,
48
- parseUuid,
49
- } from '@assembly-js/app-foundation/server';
50
- import { db } from '@/lib/db';
51
- import { items } from '@/lib/schema';
52
- import { eq } from 'drizzle-orm';
53
-
54
- export async function GET(
55
- request: NextRequest,
56
- { params }: { params: Promise<{ id: string }> },
57
- ) {
58
- const auth = await requireAuth(request);
59
- if (auth instanceof NextResponse) return auth;
60
-
61
- const { id: rawId } = await params;
62
- // items.id is uuid — validate at the lookup, not because the segment is named [id]
63
- const id = parseUuid(rawId);
64
- if (id instanceof NextResponse) return id;
65
-
66
- try {
67
- const item = await db.query.items.findFirst({ where: eq(items.id, id) });
68
- if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 });
69
-
70
- // Only needed for the multi-company membership check; omit otherwise.
71
- const assembly = await getAssemblyForRequest(request);
72
- if (assembly instanceof NextResponse) return assembly;
73
- // Pass both scope columns — clientId is what lets a client-scoped row
74
- // (companyId null) pass for its owning client.
75
- const scopeError = await requireCompanyScope(
76
- auth,
77
- { companyId: item.companyId, clientId: item.clientId },
78
- assembly,
79
- );
80
- if (scopeError) return scopeError;
81
-
82
- return NextResponse.json(item);
83
- } catch (err) {
84
- return handleRouteError(err);
85
- }
86
- }
87
- ```
88
-
89
- ## Write (POST/PATCH) example
90
-
91
- Apply the same guard before any mutation. Mutating without it lets an authenticated user from company A modify company B's data.
92
-
93
- ```ts
94
- // app/api/items/[id]/route.ts — PATCH handler in the same file
95
- export async function PATCH(
96
- request: NextRequest,
97
- { params }: { params: Promise<{ id: string }> },
98
- ) {
99
- const auth = await requireAuth(request);
100
- if (auth instanceof NextResponse) return auth;
101
-
102
- const { id: rawId } = await params;
103
- const id = parseUuid(rawId);
104
- if (id instanceof NextResponse) return id;
105
-
106
- try {
107
- const item = await db.query.items.findFirst({ where: eq(items.id, id) });
108
- if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 });
109
-
110
- const assembly = await getAssemblyForRequest(request);
111
- if (assembly instanceof NextResponse) return assembly;
112
- const scopeError = await requireCompanyScope(
113
- auth,
114
- { companyId: item.companyId, clientId: item.clientId },
115
- assembly,
116
- );
117
- if (scopeError) return scopeError;
118
-
119
- const body = await request.json();
120
- // Never spread the full body — callers could include companyId or id
121
- // and reparent / re-identify the resource. Extract only mutable fields.
122
- const { name, description } = body; // replace with the actual mutable fields
123
- await db.update(items).set({ name, description }).where(eq(items.id, id));
124
- return NextResponse.json({ ok: true });
125
- } catch (err) {
126
- return handleRouteError(err, 'Update failed');
127
- }
128
- }
129
- ```
130
-
131
- ## Sub-resource routes
132
-
133
- The same guard applies to **every level of nesting**. If a project has highlights, the highlight handler must scope to the highlight's own owning company or client — not just the parent project.
134
-
135
- Load the **sub-resource** from the database first, then call `requireCompanyScope` with the sub-resource's own `companyId` and `clientId`. Do NOT skip the scope check on the assumption that the parent route already guards access.
136
-
137
- ```ts
138
- // app/api/projects/[id]/highlights/[highlightId]/route.ts
139
- import { NextRequest, NextResponse } from 'next/server';
140
- import {
141
- requireAuth,
142
- requireCompanyScope,
143
- getAssemblyForRequest,
144
- handleRouteError,
145
- parseUuid,
146
- } from '@assembly-js/app-foundation/server';
147
- import { db } from '@/lib/db';
148
- import { highlights } from '@/lib/schema';
149
- import { eq } from 'drizzle-orm';
150
-
151
- export async function GET(
152
- request: NextRequest,
153
- { params }: { params: Promise<{ id: string; highlightId: string }> },
154
- ) {
155
- const auth = await requireAuth(request);
156
- if (auth instanceof NextResponse) return auth;
157
-
158
- const { highlightId: rawHighlightId } = await params;
159
- // highlights.id is uuid — validate at the lookup
160
- const highlightId = parseUuid(rawHighlightId, 'highlight id');
161
- if (highlightId instanceof NextResponse) return highlightId;
162
-
163
- try {
164
- const highlight = await db.query.highlights.findFirst({
165
- where: eq(highlights.id, highlightId),
166
- });
167
- if (!highlight) return NextResponse.json({ error: 'Not found' }, { status: 404 });
168
-
169
- const assembly = await getAssemblyForRequest(request);
170
- if (assembly instanceof NextResponse) return assembly;
171
- const scopeError = await requireCompanyScope(
172
- auth,
173
- { companyId: highlight.companyId, clientId: highlight.clientId },
174
- assembly,
175
- );
176
- if (scopeError) return scopeError;
177
-
178
- return NextResponse.json(highlight);
179
- } catch (err) {
180
- return handleRouteError(err);
181
- }
182
- }
183
- ```
184
-
185
- The same pattern applies to milestones, comments, assets, or any other sub-resource nested under a parent `[id]` route.
186
-
187
- ## The rule
188
-
189
- Every `[id]`-nested route handler — regardless of HTTP method or nesting depth — must follow this sequence:
190
-
191
- 1. `const auth = await requireAuth(request); if (auth instanceof NextResponse) return auth;`
192
- 2. Load the resource (or sub-resource) from the database inside `try/catch`. If the lookup uses a uuid column, validate with `parseUuid` first (see `assembly-route-errors`). Use `handleRouteError` in the catch — never return `err.message`.
193
- 3. Return 404 if not found.
194
- 4. `const assembly = await getAssemblyForRequest(request); if (assembly instanceof NextResponse) return assembly;`
195
- 5. `const scopeError = await requireCompanyScope(auth, { companyId: resource.companyId, clientId: resource.clientId }, assembly); if (scopeError) return scopeError;`
196
- 6. Return data or perform the mutation.
197
-
198
- Never skip steps 1 or 5, even on GET handlers.
@@ -1,156 +0,0 @@
1
- ---
2
- name: assembly-session-token
3
- description: Use any time the app reads identity, fetches data, or touches auth/token handling. Covers the bearer lifecycle (session token, bearer cookie, getAssembly(), silent re-exchange), getSessionIdentity/useAssembly, useServerAction, authFetch. See assembly-route-auth for requireAuth/requireCompanyScope.
4
- ---
5
-
6
- Auth is **cookie-borne and automatic** — you never handle the session token and you never pass a token to anything. On the first load only, the parent opens your app at `…/?token=<session-token>`. The foundation middleware (`proxy.ts`) exchanges that session token exactly once (server-side, via `POST /v1/session`) for a short-lived **user-scoped bearer** and stores it in an HttpOnly, `SameSite=None` cookie. That mint is the only time the session token is ever used. Every server call your app makes reads the bearer from that cookie — `getAssembly()` returns an authenticated client with no token argument. The bearer lasts 60 minutes; when it expires, `authFetch` and `useServerAction` silently re-exchange (mint a fresh bearer from the parent's current session token via `/api/assembly-reexchange`) and retry — no reload, no code from you.
7
-
8
- Your job is to follow three rules.
9
-
10
- ## Rule 1 — read identity from `getSessionIdentity()` (server) or `useAssembly()` (client)
11
-
12
- Never decode a token yourself and never call `AssemblyBridge.sessionToken.*` — that API is the foundation's, not yours.
13
-
14
- Server-side, `getSessionIdentity()` returns the viewer the platform verified for the bearer (no token to pass; the check is cached briefly per server instance). It returns `null` when unauthenticated, and throws `AuthUnavailableError` when the platform could not be reached to verify — that is not an expiry, so don't catch it into `null`, don't re-exchange on it, and render a retry state ("Couldn't load, try again" with a retry button) where a server component reads identity:
15
-
16
- ```ts
17
- import { getSessionIdentity } from '@assembly-js/app-foundation/server';
18
-
19
- const id = await getSessionIdentity();
20
- // { workspaceId, clientId?, companyId?, internalUserId? } | null
21
- if (id?.internalUserId) {
22
- // internal staff — render the staff view
23
- } else if (id?.clientId) {
24
- // client user — render their own view
25
- }
26
- ```
27
-
28
- Client-side, the same fields come from `useAssembly()` — seeded from the server, so there's no token and no loading race:
29
-
30
- ```tsx
31
- 'use client';
32
- import { useAssembly } from '@/app/components/assembly-context';
33
-
34
- export function Greeting() {
35
- const { isClient, internalUserId, clientId, workspaceId } = useAssembly();
36
- return <p>{isClient ? 'Welcome' : 'Hello, teammate'}</p>;
37
- }
38
- ```
39
-
40
- Branch your UI on identity **before** making data calls — it's faster and more reliable than catching a 403.
41
-
42
- ## Rule 2 — fetch data with `useServerAction` (the bearer rides the cookie for you)
43
-
44
- Data fetching is done with **SDK methods inside server actions**, not by calling the Assembly API from the browser. Write a `'use server'` action that takes **no token** and calls `getAssembly()`, then run it from the client through `useServerAction` — which manages loading/error state and transparently re-exchanges + retries once if the bearer has expired:
45
-
46
- ```ts
47
- // app/actions/clients.ts
48
- 'use server';
49
- import { getAssembly } from '@assembly-js/app-foundation/server';
50
-
51
- export async function listClients() {
52
- const assembly = await getAssembly(); // throws TokenInvalidError if the bearer is gone
53
- return assembly.listClients({});
54
- }
55
- ```
56
-
57
- ```tsx
58
- // app/components/client-list.tsx
59
- 'use client';
60
- import { useServerAction } from '@/app/components/use-server-action';
61
- import { listClients } from '@/app/actions/clients';
62
-
63
- export function ClientList() {
64
- const { data, isLoading, error, reload } = useServerAction(() => listClients(), []);
65
- // ...
66
- }
67
- ```
68
-
69
- `deps` works like a `useEffect` dependency array, with one rule: **deps must be primitives** — an id, a cursor, a `length`, a boolean. A derived array or object (`records`, `{ baseId }`, `items.filter(...)`) is a new identity on every render, so the action re-runs, its state update re-renders, and the page spins forever without a network call or an error to point at. Pass `records.length` or the id the array came from instead.
70
-
71
- `deps` works like a `useEffect` dependency array — list any values your action closes over so it re-runs when they change.
72
-
73
- For an imperative one-off (a button handler, a mutation), use `callAction` from `useAssembly()` — same re-exchange-and-retry behavior, no hook:
74
-
75
- ```tsx
76
- const { callAction } = useAssembly();
77
- async function save(values: FormData) {
78
- await callAction(() => updateClient(values));
79
- }
80
- ```
81
-
82
- **Data server actions must let `TokenInvalidError` propagate** — do not wrap `getAssembly`/SDK calls in a `try/catch` that swallows the error into `null` or an empty result. The silent re-exchange only fires if the action *surfaces* the expiry.
83
-
84
- Do **not** hand-roll refresh logic: no bridge polling, no manual re-exchange + retry, no token subscriptions in components. `useServerAction`/`callAction` already do it.
85
-
86
- ## Rule 3 — never touch the token in app code
87
-
88
- By the time your code runs, the bearer is already in the cookie. So:
89
-
90
- - **Never read `searchParams.token`** — not even on the entry route. The `?token=` on first load is the middleware's to consume, once. `getAssembly()` / `getSessionIdentity()` read the bearer cookie instead.
91
- - **Never put `?token=` in a `<Link>` / `router.push`.** Inner pages need nothing from the URL to be authenticated — auth is in the cookie. There is no `TokenLink`/`useWithToken`.
92
- - **Never pass a token to any function.** `getAssembly()`, server actions, and route handlers all take **no** token argument.
93
- - **Never store a token** in `useState`/`useRef`, a cookie you set, or anywhere else.
94
-
95
- ```tsx
96
- // app/page.tsx — the entry page reads NO token; auth is already in the cookie.
97
- import { getAssembly } from '@assembly-js/app-foundation/server';
98
-
99
- export default async function Page() {
100
- const assembly = await getAssembly();
101
- // ...render initial server data, then hand off to client components that use useAssembly()
102
- }
103
- ```
104
-
105
- ## Route handlers — `authFetch` + `getAssemblyForRequest` (only when a route handler is the right tool)
106
-
107
- Server actions are the default. Use a route handler + `authFetch` only when you actually need one (streaming responses, file uploads, third-party webhooks). `authFetch` (from `app/components/lib-client.ts`) adds the required CSRF header; the bearer rides the cookie; it re-exchanges + retries once on a 401/403 from a bearer expiry:
108
-
109
- ```tsx
110
- 'use client';
111
- import { authFetch } from '@/app/components/lib-client';
112
- const res = await authFetch('/api/upload', { method: 'POST', body });
113
- ```
114
-
115
- The handler authenticates with `getAssemblyForRequest(request)`, which returns a bearer-authenticated client or a 401 `NextResponse` (carrying the `x-assembly-token-expired` marker) — just return the `NextResponse` and `authFetch` re-exchanges and retries:
116
-
117
- ```ts
118
- import { getAssemblyForRequest } from '@assembly-js/app-foundation/server';
119
-
120
- export async function POST(request: NextRequest) {
121
- const assembly = await getAssemblyForRequest(request);
122
- if (assembly instanceof NextResponse) return assembly;
123
- // ...use assembly
124
- }
125
- ```
126
-
127
- For `[id]`-scoped routes, guard with `requireAuth` then `requireCompanyScope` (see [[assembly-route-auth]]). `requireAuth` returns 401 (+ `x-assembly-token-expired` header) on an expired bearer; a 403 from `requireCompanyScope` is a permanent cross-tenant denial — a re-exchange will not fix it.
128
-
129
- ## Background / offline work (cron, webhooks — no user present)
130
-
131
- Everything above is **request-scoped**: a user is in the loop and `getAssembly()` acts as that viewer. When there is no user session — a scheduled job or an incoming webhook — there's no bearer cookie to read. For those, call `getOfflineAssembly()` from `@assembly-js/app-foundation/server`: it mints an **app-scoped offline bearer** from your API key server-side and returns a bearer-only client.
132
-
133
- ```ts
134
- // app/api/cron/sync/route.ts — no viewer, no cookie
135
- import { getOfflineAssembly, listAll } from '@assembly-js/app-foundation/server';
136
-
137
- export async function GET() {
138
- const assembly = await getOfflineAssembly();
139
- // Background work must cover everyone — walk every page, not just the first.
140
- const clients = await listAll((nextToken) => assembly.listClients({ nextToken, limit: 100 }));
141
- // …background work…
142
- return Response.json({ ok: true });
143
- }
144
- ```
145
-
146
- It **throws** (not returns null) if `ASSEMBLY_API_KEY` is missing or the mint fails — there's no client to re-exchange in the background, so let it propagate. `getSessionIdentity()` returns `null` on this path (the bearer is the app, not a person). Only reach for this outside a request — using it inside a user request bypasses that viewer's own scope; ignore it for normal UI work.
147
-
148
- ## What NOT to do
149
-
150
- - **No `AssemblyBridge.sessionToken.*` in app components** — the foundation owns the bridge; you authenticate with `getAssembly()`.
151
- - **No token in component `useState`/`useRef`** — there's no client-side token at all.
152
- - **No hand-rolled refresh/retry** — `useServerAction`/`callAction`/`authFetch` handle bearer expiry.
153
- - **No client-side `assemblyApi` calls** — every API call is server-side through `getAssembly()` / `getAssemblyForRequest()`.
154
- - **No cookies you set, no `x-assembly-token` header, no `?token=` proxy, no `TokenLink`/`useWithToken`** — the bearer cookie is the whole story.
155
- - **No `searchParams.token` anywhere** — not even the entry route.
156
- - **Don't swallow `TokenInvalidError` in data actions** — let it propagate so the re-exchange-and-retry can fire.