@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.
@@ -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.