@assembly-js/app-foundation 0.8.2 → 0.10.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,16 @@ 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.10.0
23
+ - [behavior] A call to another app through the installer client or a raw `fetch` to `/v1/apps/...` made inside a client's request now carries that client, so the platform checks the client could open the other app and the other app sees a client whose calling app is this one. A client who cannot open it gets the platform's `viewer_cannot_open_app` refusal as an error value; staff requests, cron, webhooks and queues are unchanged and send no parent.
24
+ - [behavior] A cross-app call from a client's request whose session cannot be verified (exchange off, a rejected header or expired cookie) now throws the token-invalid error instead of running as the installing admin, and a cross-app call where the request cannot be read (`'use cache'`, prerender, `after()`) throws. Storage, connector, notification and AI proxy installer calls are untouched.
25
+ - [action] A route that allows app callers but not clients (`allow: ['app']` or `['internal', 'app']`) now answers 403 to a client-originated installer call, because the person wins over the calling app. Add `'client'` to the list if clients should reach the route, and scope what it returns to that client; `bin/check-app-only-routes.mjs` lists the routes; mark one that should stay app-only with a `check-app-only-routes: intended` comment.
26
+ - [behavior] The action context's `caller.chain` and `caller.hops` now come from the verified bearer instead of a fixed single hop; they are advisory.
27
+
28
+ ## 0.9.0
29
+ - [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.
30
+ - [guidance] `check-token-usage` points at the `assembly-auth` skill.
31
+
22
32
  ## 0.8.2
23
33
  - [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.
24
34
 
package/CLAUDE.md CHANGED
@@ -69,8 +69,7 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
69
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.
70
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.
71
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.
72
- - `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.
73
- - `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.
74
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.
75
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).
76
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.
@@ -525,7 +524,7 @@ Two things flow into the running app from Assembly:
525
524
  | | Where | Visibility |
526
525
  |---|---|---|
527
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. |
528
- | 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". |
529
528
 
530
529
  ---
531
530
 
@@ -915,315 +914,29 @@ export async function GET(request: NextRequest) {
915
914
  }
916
915
  ```
917
916
 
918
- ## How auth works — read this first (it's automatic, and not what you'd guess)
917
+ ## Auth — automatic; read the `assembly-auth` skill before writing any route, server action or client call
919
918
 
920
- 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:
919
+ The foundation runs the whole session; you write none of it:
921
920
 
922
- 1. **On the first load only**, the parent opens your app at `…/?token=<session-token>`.
923
- 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.
924
- 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.
925
- 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
+ - 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.
926
926
 
927
- Internalize these consequences — they are exactly what generated apps get wrong:
928
- - **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.
929
- - **Never pass a token to any function.** `getAssembly()`, server actions, and route handlers all take **no** token argument.
930
- - **Never call `assemblyApi` yourself, and never authenticate on the client.** Every API call is server-side through `getAssembly()` / `getAssemblyForRequest()`.
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.
931
928
 
932
- Every authenticated call your app makes is one of exactly three kinds:
933
- 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.
934
- 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.
935
- 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.
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.
936
933
 
937
- ### `getAssembly()` — the only auth helper you need
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.
938
935
 
939
- `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:
940
-
941
- ```ts
942
- import { getAssembly } from '@assembly-js/app-foundation/server';
943
-
944
- const assembly = await getAssembly(); // no token — the bearer comes from the cookie
945
- const items = await assembly.listItems({});
946
- ```
947
-
948
- `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.
949
-
950
- ### Identity — who is the viewer?
951
-
952
- Server-side, `getSessionIdentity()` returns the viewer the platform verified for the bearer (no token to pass; the check is cached briefly per server instance):
953
-
954
- ```ts
955
- import { getSessionIdentity } from '@assembly-js/app-foundation/server';
956
-
957
- const id = await getSessionIdentity();
958
- // { workspaceId, clientId?, companyId?, internalUserId? } | null
959
- if (id?.internalUserId) {
960
- // internal staff — render the staff view
961
- } else if (id?.clientId) {
962
- // client user — render their own view
963
- }
964
- ```
965
-
966
- Client-side, `useAssembly()` exposes the same identity, seeded from the server — **no token, no loading state**:
967
-
968
- ```tsx
969
- 'use client';
970
- import { useAssembly } from '@/app/components/assembly-context';
971
-
972
- export default function MyWidget() {
973
- const { workspaceId, clientId, internalUserId, isClient } = useAssembly();
974
- return <div>{isClient ? 'Client view' : 'Staff view'}</div>;
975
- }
976
- ```
977
-
978
- Branch your UI on identity **before** making data calls — it's faster and more reliable than catching a 403.
979
-
980
- ### Fetching data — server actions via `useServerAction`
981
-
982
- 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:
983
-
984
- ```ts
985
- // app/actions/items.ts
986
- 'use server';
987
- import { getAssembly } from '@assembly-js/app-foundation/server';
988
-
989
- export async function loadItems() {
990
- const assembly = await getAssembly(); // throws TokenInvalidError if the bearer is gone
991
- return assembly.listItems({});
992
- }
993
- ```
994
-
995
- ```tsx
996
- // app/components/my-widget.tsx
997
- 'use client';
998
- import { useServerAction } from '@/app/components/use-server-action';
999
- import { loadItems } from '@/app/actions/items';
1000
-
1001
- export default function MyWidget() {
1002
- const { data: items, isLoading, error, reload } = useServerAction(() => loadItems(), []);
1003
- // `deps` ([]) works like useEffect deps — list anything the action closes over.
1004
- // Deps must be primitives (an id, a cursor, `items.length`). A derived array or
1005
- // object is a new identity every render and spins the page in a fetch loop.
1006
- }
1007
- ```
1008
-
1009
- For an imperative one-off (button handler, mutation), use `callAction` from `useAssembly()` — same silent re-exchange + retry, no hook: `await callAction(() => updateItem(values))`.
1010
-
1011
- **Two hard rules** (this is exactly what generated apps get wrong):
1012
- - **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`.
1013
- - **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.
1014
-
1015
- ### Server action or route handler?
1016
-
1017
- 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.
1018
-
1019
- 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:
1020
-
1021
- ```ts
1022
- // lib/reply-contract.ts — imported by both the route and the component (`bun add zod` if it is not installed yet)
1023
- import { z } from 'zod';
1024
- export const replyBody = z.object({ parentId: z.string().uuid().nullish(), text: z.string().min(1) });
1025
- export type ReplyBody = z.input<typeof replyBody>;
1026
-
1027
- // app/api/replies/route.ts: const parsed = replyBody.safeParse(await request.json());
1028
- // component: const body: ReplyBody = { parentId, text }; // tsc rejects what the route would 400
1029
- ```
1030
-
1031
- ### Client → server via route handlers — `authFetch`
1032
-
1033
- 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:
1034
-
1035
- ```tsx
1036
- 'use client';
1037
- import { authFetch } from '@/app/components/lib-client';
1038
-
1039
- async function saveFlavor(clientId: string, flavor: string) {
1040
- const res = await authFetch(`/api/clients/${clientId}/flavor`, {
1041
- method: 'PATCH',
1042
- headers: { 'Content-Type': 'application/json' },
1043
- body: JSON.stringify({ flavor }),
1044
- });
1045
- if (!res.ok) throw new Error(await res.text());
1046
- return res.json();
1047
- }
1048
- ```
1049
-
1050
- 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.
1051
-
1052
- 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.
1053
-
1054
- ### Route handlers — `getAssemblyForRequest`
1055
-
1056
- ```ts
1057
- // app/api/clients/[clientId]/flavor/route.ts
1058
- import { NextRequest, NextResponse } from 'next/server';
1059
- import { getAssemblyForRequest } from '@assembly-js/app-foundation/server';
1060
-
1061
- export async function PATCH(
1062
- request: NextRequest,
1063
- { params }: { params: Promise<{ clientId: string }> },
1064
- ) {
1065
- const { clientId } = await params;
1066
- const assembly = await getAssemblyForRequest(request);
1067
- if (assembly instanceof NextResponse) return assembly; // 401 → authFetch re-exchanges
1068
-
1069
- const { flavor } = await request.json();
1070
- await assembly.updateClient({
1071
- id: clientId,
1072
- requestBody: {
1073
- customFields: { pizza_flavor: flavor },
1074
- },
1075
- });
1076
- return NextResponse.json({ ok: true });
1077
- }
1078
- ```
1079
-
1080
- `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).
1081
-
1082
- **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`.
1083
937
 
1084
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.
1085
939
 
1086
- ### Offline bearer — background work with no user (cron, webhooks)
1087
-
1088
- **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.
1089
-
1090
- 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.
1091
-
1092
- ```ts
1093
- // app/api/cron/digest/route.ts — Vercel cron or a webhook handler
1094
- import { getOfflineAssembly, listAll } from '@assembly-js/app-foundation/server';
1095
-
1096
- export async function GET() {
1097
- const assembly = await getOfflineAssembly(); // app-scoped, no cookie needed
1098
- // Background work must cover everyone — walk every page (see Pagination).
1099
- const clients = await listAll((nextToken) =>
1100
- assembly.listClients({ nextToken, limit: 100 }),
1101
- );
1102
- // …do the background work (send a digest, reconcile state, etc.)…
1103
- return Response.json({ ok: true });
1104
- }
1105
- ```
1106
-
1107
- Rules for the offline path:
1108
- - **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.
1109
- - **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.
1110
- - **`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.
1111
- - 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.
1112
-
1113
- ### Auth guards for `[id]`-nested routes — `requireAuth` and `requireCompanyScope`
1114
-
1115
- Two pre-built helpers guard API routes that operate on company-scoped resources:
1116
-
1117
- ```ts
1118
- import { requireAuth, requireCompanyScope } from '@assembly-js/app-foundation/server';
1119
- ```
1120
-
1121
- **`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.
1122
-
1123
- **`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`.
1124
-
1125
- ```ts
1126
- // app/api/items/[id]/route.ts
1127
- import { NextRequest, NextResponse } from 'next/server';
1128
- import {
1129
- requireAuth,
1130
- requireCompanyScope,
1131
- getAssemblyForRequest,
1132
- handleRouteError,
1133
- parseUuid,
1134
- } from '@assembly-js/app-foundation/server';
1135
- import { db } from '@/lib/db';
1136
- import { items } from '@/lib/schema';
1137
- import { eq } from 'drizzle-orm';
1138
-
1139
- export async function GET(
1140
- request: NextRequest,
1141
- { params }: { params: Promise<{ id: string }> },
1142
- ) {
1143
- const auth = await requireAuth(request);
1144
- if (auth instanceof NextResponse) return auth;
1145
-
1146
- const { id: rawId } = await params;
1147
- // items.id is uuid — validate at the lookup, not because the segment is named [id]
1148
- const id = parseUuid(rawId);
1149
- if (id instanceof NextResponse) return id;
1150
-
1151
- try {
1152
- const item = await db.query.items.findFirst({ where: eq(items.id, id) });
1153
- if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 });
1154
-
1155
- // Only needed for the multi-company membership check; omit otherwise.
1156
- const assembly = await getAssemblyForRequest(request);
1157
- if (assembly instanceof NextResponse) return assembly;
1158
- // Pass both scope columns — clientId is what lets a client-scoped row
1159
- // (companyId null) pass for its owning client.
1160
- const scopeError = await requireCompanyScope(
1161
- auth,
1162
- { companyId: item.companyId, clientId: item.clientId },
1163
- assembly,
1164
- );
1165
- if (scopeError) return scopeError;
1166
-
1167
- return NextResponse.json(item);
1168
- } catch (err) {
1169
- return handleRouteError(err);
1170
- }
1171
- }
1172
- ```
1173
-
1174
- ---
1175
-
1176
- ## Session & bearer lifecycle (under the hood — you write none of it)
1177
-
1178
- You don't manage sessions; the foundation does. This is the flow so you understand where auth comes from:
1179
-
1180
- - The parent opens your app with `?token=<session-token>` **once** (5-minute TTL, encrypted, rotated by the parent).
1181
- - 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.
1182
- - Every server call uses the bearer; `getAssembly()` reads the cookie.
1183
- - 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.
1184
-
1185
- 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.
1186
-
1187
- ### Identify the viewer
1188
-
1189
- 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).
1190
-
1191
- Server-side (`getSessionIdentity()` returns the platform-verified viewer; no token to pass):
1192
-
1193
- ```ts
1194
- import { getSessionIdentity } from '@assembly-js/app-foundation/server';
1195
-
1196
- const id = await getSessionIdentity();
1197
- // { workspaceId, internalUserId?, clientId?, companyId? } | null
1198
- if (id?.internalUserId) {
1199
- // internal staff — render the staff view (lists, tables, admin actions)
1200
- } else if (id?.clientId) {
1201
- // client user — render their own view (profile / settings)
1202
- } else {
1203
- // no session — render an unauthenticated state
1204
- }
1205
- ```
1206
-
1207
- 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):
1208
-
1209
- ```ts
1210
- const assembly = await getAssembly();
1211
- const id = await getSessionIdentity();
1212
-
1213
- if (id?.internalUserId) {
1214
- const me = await assembly.retrieveInternalUser({ id: id.internalUserId });
1215
- return { type: 'internal' as const, user: me };
1216
- }
1217
- if (id?.clientId) {
1218
- const me = await assembly.retrieveClient({ id: id.clientId });
1219
- const company = id.companyId
1220
- ? await assembly.retrieveCompany({ id: id.companyId })
1221
- : null;
1222
- return { type: 'client' as const, user: me, company };
1223
- }
1224
- return { type: 'app' as const };
1225
- ```
1226
-
1227
940
  ---
1228
941
 
1229
942
  ## Server-side SDK: `@assembly-js/node-sdk`
@@ -1467,7 +1180,7 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
1467
1180
 
1468
1181
  ### Session token — the foundation uses this, not you
1469
1182
 
1470
- `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()`.
1471
1184
 
1472
1185
  ---
1473
1186
 
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ // Flags routes whose allow list admits app callers but not clients. A client who
3
+ // reaches this app through another app's installer call carries the client, and
4
+ // a person wins over a calling app, so such a route now answers 403 to that
5
+ // call. Dependency-free on purpose: it runs inside the generated app's sandbox
6
+ // before any deps beyond Node exist.
7
+ //
8
+ // A route that should stay app-only says so with a comment on the list's line or
9
+ // the line above, `check-app-only-routes: intended`, and passes.
10
+ //
11
+ // A hit is a warning on an ordinary turn and fails the build with --strict (the
12
+ // update acceptance), on a platform update turn, or when
13
+ // ASSEMBLY_TURN_BASELINE_SHA says this turn changed the file. An app with no
14
+ // such list passes.
15
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
16
+ import { spawnSync } from 'node:child_process';
17
+ import { join, relative } from 'node:path';
18
+
19
+ const ROOT = process.cwd();
20
+ const STRICT = process.argv.includes('--strict') || process.env.ASSEMBLY_UPDATE_TURN === '1';
21
+ const TURN_BASELINE_ENV = 'ASSEMBLY_TURN_BASELINE_SHA';
22
+
23
+ // Route code lives under app/ and shared auth wrappers under lib/.
24
+ const SCAN_DIRS = ['app', 'lib'];
25
+ const SOURCE_FILE = /\.(ts|tsx|js|jsx|mjs)$/;
26
+ const SKIP_DIRS = new Set(['node_modules', '.next', '.git', 'generated']);
27
+ const ALLOW_LIST = /\ballow\s*:\s*\[([^\]]*)\]/g;
28
+ const ENTRY = /['"`]([a-z]+)['"`]/g;
29
+ const LINE_COMMENT = /^\s*\/\//;
30
+ const ACKNOWLEDGED = /check-app-only-routes:\s*intended/;
31
+
32
+ function appOnlyAllowLists(source) {
33
+ // Whole-line comments only: stripping a trailing `//` would eat the `//` in a URL.
34
+ const code = source
35
+ .split('\n')
36
+ .map((line) => (LINE_COMMENT.test(line) ? '' : line))
37
+ .join('\n');
38
+ const lines = source.split('\n');
39
+ const hits = [];
40
+ for (const match of code.matchAll(ALLOW_LIST)) {
41
+ const kinds = [...match[1].matchAll(ENTRY)].map((m) => m[1]);
42
+ if (kinds.includes('app') && !kinds.includes('client')) {
43
+ const line = code.slice(0, match.index).split('\n').length;
44
+ if (ACKNOWLEDGED.test(`${lines[line - 2] ?? ''}\n${lines[line - 1] ?? ''}`)) continue;
45
+ hits.push({ list: match[0].replace(/\s+/g, ' '), line });
46
+ }
47
+ }
48
+ return hits;
49
+ }
50
+
51
+ function* sourceFiles(dir) {
52
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
53
+ if (entry.isDirectory()) {
54
+ if (!SKIP_DIRS.has(entry.name)) yield* sourceFiles(join(dir, entry.name));
55
+ } else if (SOURCE_FILE.test(entry.name)) {
56
+ yield join(dir, entry.name);
57
+ }
58
+ }
59
+ }
60
+
61
+ function changedFiles() {
62
+ const baseline = (process.env[TURN_BASELINE_ENV] ?? '').trim();
63
+ if (!baseline) return new Set();
64
+ const listings = [
65
+ { args: ['diff', '--name-only', `${baseline}..HEAD`], parse: (line) => line },
66
+ { args: ['status', '--porcelain', '--untracked-files=all'], parse: (line) => line.slice(3).split(' -> ').pop() },
67
+ ];
68
+ const changed = new Set();
69
+ for (const { args, parse } of listings) {
70
+ const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
71
+ // Cannot tell what changed: treat every file as changed rather than pass quietly.
72
+ if (result.status !== 0) return null;
73
+ for (const line of result.stdout.split('\n')) changed.add(parse(line).trim());
74
+ }
75
+ return changed;
76
+ }
77
+
78
+ const findings = [];
79
+ for (const dir of SCAN_DIRS) {
80
+ if (!existsSync(join(ROOT, dir))) continue;
81
+ for (const file of sourceFiles(join(ROOT, dir))) {
82
+ const hits = appOnlyAllowLists(readFileSync(file, 'utf8'));
83
+ for (const hit of hits) findings.push({ file: relative(ROOT, file), ...hit });
84
+ }
85
+ }
86
+ if (findings.length === 0) process.exit(0);
87
+
88
+ const changed = changedFiles();
89
+ const fatal = STRICT || changed === null || findings.some((f) => changed.has(f.file));
90
+ const out = fatal ? console.error : console.warn;
91
+ out('');
92
+ out(
93
+ fatal
94
+ ? 'check-app-only-routes: these allow lists admit app callers but not clients:'
95
+ : 'check-app-only-routes: these allow lists admit app callers but not clients (pre-existing; this build is not failed for them):',
96
+ );
97
+ out('');
98
+ for (const f of findings) out(` ${f.file}:${f.line} ${f.list}`);
99
+ out('');
100
+ out('A client who reaches this app through another app now arrives as that client, and a person');
101
+ out('wins over a calling app, so a route that omits `client` answers 403 to the call. If clients');
102
+ out("may reach the route, add 'client' to the list and scope what it returns to that client;");
103
+ out('if only the app should reach it, add a comment `check-app-only-routes: intended` on the list or the line above.');
104
+ if (fatal) process.exit(1);
@@ -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
  }
@@ -18,7 +18,7 @@
18
18
  // renews the bearer cookie; it never hands out a client.
19
19
  export const NON_CLIENT_EXPORTS = new Set([
20
20
  'APP_CSRF_HEADER', 'AUTH_UNAVAILABLE_CODE', 'AUTH_UNAVAILABLE_DIGEST', 'ActionContext', 'ActionError',
21
- 'ActionErrorStatus', 'AirtableFieldSchema', 'AppProxyCaller', 'AppProxyOptions', 'AirtableFormattedField',
21
+ 'ActionErrorStatus', 'AirtableFieldSchema', 'AppKeyMintOutcome', 'AppProxyCaller', 'AppProxyOptions', 'AirtableFormattedField',
22
22
  'AirtableFormattedRecord', 'AirtableRecord', 'AirtableSchemaResult', 'AirtableTableSchema',
23
23
  'AssemblyProxyForwardContext', 'AssemblyProxyOptions', 'AuthUnavailableError',
24
24
  'BEARER_COOKIE_NAME', 'BEARER_EXPIRY_COOKIE_NAME', 'BEARER_HEADER_NAME', 'BEARER_IN_BODY_HEADER', 'BEARER_TTL_MS',
@@ -27,8 +27,8 @@ export const NON_CLIENT_EXPORTS = new Set([
27
27
  'ConnectionStatus', 'ConnectorError', 'ContractDocumentError', 'ConnectorErrorCode', 'CookieOutcome', 'CronHandler',
28
28
  'DEFAULT_LABELS', 'EXCHANGE_REJECTION_HEADER', 'ExchangeRejectedViewer', 'ExchangedSession',
29
29
  'FormatAirtableRecordsOpts', 'GenerateAppClientInput', 'GeneratedAppClient', 'GmailSendArgs', 'GmailSendResult', 'IntegrationClient',
30
- 'InternalUserAccess', 'LEGACY_APP_IDS', 'LocationReporter', 'MintFailure', 'MintOutcome', 'MintSessionOptions', 'MintedAppBearer',
31
- 'MintedBearer', 'NETWORK_CAPTURE_MESSAGE_TYPE', 'NetworkCaptureHeaderFingerprint',
30
+ 'InternalUserAccess', 'LEGACY_APP_IDS', 'LocationReporter', 'MintAppKeyOptions', 'MintFailure', 'MintOutcome', 'MintSessionOptions', 'MintedAppBearer',
31
+ 'MintedBearer', 'MintedOnBehalfOf', 'NETWORK_CAPTURE_MESSAGE_TYPE', 'NetworkCaptureHeaderFingerprint',
32
32
  'NetworkCaptureRecord', 'NotifyInternalUsersInput', 'PAINTED_BEACON_EVENT', 'PAINTED_BEACON_PATH',
33
33
  'PORTAL_ORIGIN_COOKIE_NAME', 'PdfText', 'DetectedPdfField', 'PdfFieldDetection', 'PdfFieldDetectionMethod', 'PdfFieldKind', 'PdfUnreadableError', 'PrincipalAllow', 'PrincipalKind',
34
34
  'Provider', 'ProxyOpts', 'RENEW_OUTCOME_EVENT', 'RenewOutcome', 'ReplaceChildRowsResult',
@@ -14,13 +14,21 @@ export class ActionError extends Error {
14
14
  this.status = status;
15
15
  }
16
16
  }
17
- // One hop until the bearer carries a real chain. Not a measured depth.
18
- const PLACEHOLDER_HOPS = 1;
17
+ // Used only when the verified bearer names a calling app but carries no chain
18
+ // (a bearer minted before the platform stamped one). Not a measured depth.
19
+ const FALLBACK_HOPS = 1;
19
20
  const trustedBaseUrl = process.env.ASSEMBLY_API_BASE_URL || undefined;
20
- function callerFrom(callerAppId) {
21
- if (!callerAppId)
21
+ // Chain and hops come from the verified bearer and are advisory: they label the
22
+ // request, they never gate it.
23
+ function callerFrom(identity) {
24
+ if (!identity.callerAppId)
22
25
  return undefined;
23
- return { appId: callerAppId, chain: [callerAppId], hops: PLACEHOLDER_HOPS };
26
+ const chain = identity.chain?.length ? identity.chain : [identity.callerAppId];
27
+ return {
28
+ appId: identity.callerAppId,
29
+ chain,
30
+ hops: identity.hops ?? (identity.chain?.length ? chain.length : FALLBACK_HOPS),
31
+ };
24
32
  }
25
33
  // A calling app is the viewer only when the bearer names no person. A human
26
34
  // id wins so a staff or client session that also carries a caller stays human.
@@ -78,7 +86,7 @@ export async function getRequestContext() {
78
86
  if (!identity?.workspaceId) {
79
87
  throw new TokenInvalidError('Assembly bearer is required');
80
88
  }
81
- const caller = callerFrom(identity.callerAppId);
89
+ const caller = callerFrom(identity);
82
90
  const viewer = viewerFrom(identity);
83
91
  if (!viewer) {
84
92
  throw new TokenInvalidError('Assembly bearer is required');
@@ -99,8 +99,10 @@ export declare function getOfflineAssembly(): Promise<AssemblyAPI>;
99
99
  * shown to a client must be filtered by the caller. Unlike getOfflineAssembly,
100
100
  * a viewer cookie does not make this throw — that is the point of it.
101
101
  *
102
- * Does not attach the viewer's bearer. Each request mints once; a second call
103
- * in the same request reuses that bearer.
102
+ * Does not attach the viewer's bearer to its own calls. Each request mints once;
103
+ * a second call in the same request reuses that bearer. A call through `fetch` to
104
+ * another app (`/v1/apps/...`) is the exception: in a client's request it is made
105
+ * as that client (see getCrossAppBearer), in a staff request as this app.
104
106
  */
105
107
  export declare function getInstallerAssembly(): Promise<AssemblyAPI>;
106
108
  export type AppProxyCaller = 'viewer' | 'installer';
@@ -114,9 +116,11 @@ export interface AppProxyOptions {
114
116
  * `createClient`. The bearer is resolved on every call, so a client built at
115
117
  * module scope still authenticates as the current request.
116
118
  *
117
- * `installer` calls as this app, with the same per-request mint as
118
- * getInstallerAssembly. `viewer` forwards the signed-in user's verified bearer,
119
- * which the platform scopes to that contact and refuses for a team member.
119
+ * `installer` calls as this app. In a client's request it also carries that
120
+ * client, so the platform checks the client could open the other app and the
121
+ * other app sees a client; in a staff request it is this app alone. `viewer`
122
+ * forwards the signed-in user's verified bearer, which the platform scopes to
123
+ * that contact and refuses for a team member.
120
124
  */
121
125
  export declare function appProxyOptions(appId: string, caller: AppProxyCaller): AppProxyOptions;
122
126
  export declare function getAssemblyForRequest(req: NextRequest): Promise<AssemblyAPI | NextResponse>;