@assembly-js/app-foundation 0.0.39 → 0.0.40

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/CLAUDE.md CHANGED
@@ -254,7 +254,7 @@ Two things flow into the running app from Assembly:
254
254
  | | Where | Visibility |
255
255
  |---|---|---|
256
256
  | `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. |
257
- | 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()` decodes the viewer. See "How auth works". |
257
+ | 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". |
258
258
 
259
259
  ---
260
260
 
@@ -647,11 +647,11 @@ const assembly = await getAssembly(); // no token — the bearer comes from
647
647
  const items = await assembly.listItems({});
648
648
  ```
649
649
 
650
- `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.
650
+ `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.
651
651
 
652
652
  #### Identity — who is the viewer?
653
653
 
654
- Server-side, `getSessionIdentity()` returns the viewer decoded from the bearer (no token, no API call):
654
+ Server-side, `getSessionIdentity()` returns the viewer the platform verified for the bearer (no token to pass; the check is cached briefly per server instance):
655
655
 
656
656
  ```ts
657
657
  import { getSessionIdentity } from '@assembly-js/app-foundation/server';
@@ -816,7 +816,7 @@ Two pre-built helpers guard API routes that operate on company-scoped resources:
816
816
  import { requireAuth, requireCompanyScope } from '@assembly-js/app-foundation/server';
817
817
  ```
818
818
 
819
- **`requireAuth(req)`** decodes the viewer's identity from the bearer and returns it. Returns a 401 `NextResponse` with a `{ code: 'token_expired' }` body **and** `x-assembly-token-expired: 1` header when the bearer is missing/expired — `authFetch` detects this and re-exchanges + retries automatically.
819
+ **`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.
820
820
 
821
821
  **`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`.
822
822
 
@@ -886,7 +886,7 @@ So there is nothing to refresh, subscribe to, or thread. Don't touch `AssemblyBr
886
886
 
887
887
  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).
888
888
 
889
- Server-side (`getSessionIdentity()` decodes the bearer; no API call):
889
+ Server-side (`getSessionIdentity()` returns the platform-verified viewer; no token to pass):
890
890
 
891
891
  ```ts
892
892
  import { getSessionIdentity } from '@assembly-js/app-foundation/server';
@@ -1176,7 +1176,7 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
1176
1176
  - ❌ Don't make Assembly API calls from client components without a server-side proxy. The apiKey can't leave the server.
1177
1177
  - ❌ Don't reinvent Clients, Companies, or custom fields — they are always available via the SDK. For Messages, Files, Forms, Tasks, Billing, Contracts, Notes, or Knowledge Base: confirm the module is installed (`listAppInstalls`) first; if it is, call the SDK methods listed above rather than rebuilding it. If it is not installed, do not suggest it or plan around it.
1178
1178
  - ❌ Don't use `assembly.clients.list()` — the SDK is flat. Use `assembly.listClients(...)`.
1179
- - ❌ Don't cache decoded payloads for >30s — token rotates every 5 min and the user could be different on the next call (rare, but possible if a session is shared across tabs).
1179
+ - ❌ Don't cache a viewer's identity yourself. The foundation already caches the platform's verification for up to 60 seconds per server instance; a longer cache of your own can outlive a session change (a shared session across tabs can switch viewers).
1180
1180
  <!-- !feature:app-owned-header -->
1181
1181
  - ❌ Don't render your own breadcrumbs / titles in the DOM — use `AssemblyBridge.header.*`.
1182
1182
  - ❌ Don't render primary page-level actions (Submit, Save, Edit, Create) as `<Button>` elements inside the page body. These MUST go in the header via the `useAssemblyHeader` hook's `primaryCta`. Contextual inline actions (e.g. row-level edit icons, list "Add item" buttons) may stay in-page. In-page primary CTAs make the UX inconsistent with the rest of the portal. **Exception: the notification surface (`/n`).** The Notification Center renders `/n` in a headerless iframe — `useAssemblyHeader` is ignored. On this surface, primary actions MUST be inline `<Button>` elements.
@@ -1,4 +1,5 @@
1
1
  import { NextResponse } from 'next/server';
2
+ import { AuthUnavailableError, authUnavailableResponse } from './assembly.js';
2
3
  export function badRequest(message) {
3
4
  return NextResponse.json({ error: message }, { status: 400 });
4
5
  }
@@ -26,6 +27,10 @@ function isInvalidInputDbError(err) {
26
27
  // to the UI when handlers do that.
27
28
  export function handleRouteError(err, fallback = 'Something went wrong') {
28
29
  console.error(err);
30
+ // A platform outage is not an app bug; surface it as a retryable 503.
31
+ if (err instanceof AuthUnavailableError) {
32
+ return authUnavailableResponse();
33
+ }
29
34
  if (isInvalidInputDbError(err)) {
30
35
  return badRequest('Invalid request');
31
36
  }
@@ -19,6 +19,21 @@ export declare class TokenInvalidError extends Error {
19
19
  readonly code = "TOKEN_INVALID";
20
20
  constructor(message?: string);
21
21
  }
22
+ /**
23
+ * Thrown when the platform could not be reached to verify the viewer's bearer
24
+ * (outage, throttle, timeout). It says nothing about the bearer, so it is NOT a
25
+ * token-invalid error: do not re-exchange on it (that would spend a single-use
26
+ * session token for nothing) and do not render a signed-out state. Render a
27
+ * retry state instead. Distinct `name`/`code` from TokenInvalidError on purpose.
28
+ *
29
+ * Code you write follows that. The Server Action helper cannot: production redacts
30
+ * the error across the boundary, so it renews on any failure and instead bounds
31
+ * how often (one renewal per window per tab).
32
+ */
33
+ export declare class AuthUnavailableError extends Error {
34
+ readonly code = "AUTH_UNAVAILABLE";
35
+ constructor(message?: string);
36
+ }
22
37
  export declare function getAssembly(input?: AssemblyInput): Promise<AssemblyAPI>;
23
38
  /**
24
39
  * Runs `task` after the response is sent (Next's `after()`), with a viewer-
@@ -48,6 +63,8 @@ export declare function getAssembly(input?: AssemblyInput): Promise<AssemblyAPI>
48
63
  export declare function afterAssembly(task: (assembly: AssemblyAPI) => Promise<unknown>, options?: {
49
64
  token?: string;
50
65
  }): void;
66
+ export declare const AUTH_UNAVAILABLE_CODE = "auth_unavailable";
67
+ export declare function authUnavailableResponse(): NextResponse;
51
68
  /**
52
69
  * OFFLINE / BACKGROUND ONLY — cron jobs, webhook handlers, and any other code
53
70
  * path where there is NO user in the loop and thus no session token or bearer
package/dist/assembly.js CHANGED
@@ -3,33 +3,55 @@ import { cookies, headers } from 'next/headers';
3
3
  import { after, NextResponse } from 'next/server';
4
4
  import { BEARER_COOKIE_NAME, BEARER_HEADER_NAME, decodeBearer, isSessionExchangeEnabled, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
5
5
  import { mintBearerFromAppKey } from './mint-bearer.js';
6
+ import { verifyBearer } from './verify-bearer.js';
6
7
  // Trusted API origin for this stack, supplied by the build environment — NOT by
7
8
  // a token claim. Empty in prod/shared-staging (the SDK's env default is already
8
9
  // correct); set to a per-dev private stack's platform-api URL otherwise. Passed
9
10
  // to the SDK as `baseUrl` so tokenless (cookie-only) calls reach the right stack.
10
11
  const trustedBaseUrl = process.env.ASSEMBLY_API_BASE_URL || undefined;
11
- // Reads the viewer bearer for the current request, independent of the exchange
12
- // flag. First the header the proxy forwards on first load (the exchange just
13
- // minted it; the Set-Cookie isn't yet visible), then the HttpOnly cookie on every
14
- // later request. Returns undefined outside a request scope (e.g. build-time
15
- // prerender) — not an error. Used both to resolve identity and, in
16
- // getOfflineAssembly, to detect that a viewer is present.
17
- async function readViewerBearer() {
12
+ // Reads the viewer bearer candidates for the current request, independent of the
13
+ // exchange flag: first the header the proxy forwards on first load (the exchange
14
+ // just minted it; the Set-Cookie isn't yet visible), then the HttpOnly cookie on
15
+ // every later request. Returns [] outside a request scope (e.g. build-time
16
+ // prerender) — not an error.
17
+ async function readBearerCandidates() {
18
18
  try {
19
19
  const fromProxy = (await headers()).get(BEARER_HEADER_NAME);
20
- if (fromProxy && decodeBearer(fromProxy))
21
- return fromProxy;
22
20
  const fromCookie = (await cookies()).get(BEARER_COOKIE_NAME)?.value;
23
- if (fromCookie && decodeBearer(fromCookie))
24
- return fromCookie;
21
+ return [fromProxy, fromCookie].filter((b) => Boolean(b));
25
22
  }
26
23
  catch {
27
- return undefined;
24
+ return [];
25
+ }
26
+ }
27
+ // First candidate that survives the no-network precheck, unverified. For callers
28
+ // that only need to carry the credential across a boundary (afterAssembly) and
29
+ // verify it where it is used.
30
+ async function readViewerBearer() {
31
+ return (await readBearerCandidates()).find((b) => decodeBearer(b));
32
+ }
33
+ // The viewer's bearer together with the identity the platform verified for it.
34
+ // A rejected candidate falls through to the next one, as an undecodable one
35
+ // always did. An unverifiable one stops the walk: it says nothing about the
36
+ // bearer, so the request must neither be treated as signed out nor re-exchanged.
37
+ async function readVerifiedViewer() {
38
+ for (const bearer of await readBearerCandidates()) {
39
+ const result = await verifyBearer(bearer);
40
+ if (result.ok)
41
+ return { bearer, identity: result.identity };
42
+ if (result.reason === 'unavailable')
43
+ throw new AuthUnavailableError();
28
44
  }
29
45
  return undefined;
30
46
  }
31
- // Viewer bearer for request-scoped auth: only consulted when the exchange flow is
32
- // on (the legacy path carries identity in proxy headers, not a bearer cookie).
47
+ // Verified viewer for request-scoped auth: only consulted when the exchange flow
48
+ // is on (the legacy path carries identity in proxy headers, not a bearer cookie).
49
+ async function resolveRequestViewer() {
50
+ if (!isSessionExchangeEnabled())
51
+ return undefined;
52
+ return readVerifiedViewer();
53
+ }
54
+ // Unverified bearer for deferred work; see readViewerBearer.
33
55
  async function resolveRequestBearer() {
34
56
  if (!isSessionExchangeEnabled())
35
57
  return undefined;
@@ -64,15 +86,22 @@ async function hasViewerRequest() {
64
86
  return false;
65
87
  }
66
88
  }
67
- // Server-only: the current user's identity, resolved from the bearer (the
68
- // proxy-forwarded header on first load, else the HttpOnly cookie). This is how
69
- // the app learns who the viewer is — workspace, client/company, internal user —
70
- // without ever handling the raw session token. Used to seed the client provider
71
- // and by server components. Returns null when unauthenticated or when the
72
- // exchange is disabled (the legacy path carries identity in proxy headers).
89
+ // Server-only: the current user's identity, verified by the platform for the
90
+ // bearer (the proxy-forwarded header on first load, else the HttpOnly cookie).
91
+ // This is how the app learns who the viewer is — workspace, client/company,
92
+ // internal user — without ever handling the raw session token. Used to seed the
93
+ // client provider and by server components. Returns null when unauthenticated or
94
+ // when the exchange is disabled (the legacy path carries identity in proxy
95
+ // headers). Throws AuthUnavailableError when the platform could not be reached
96
+ // to verify, so a layout can render a retry state rather than a signed-out one.
73
97
  export async function getSessionIdentity() {
74
- const bearer = await resolveRequestBearer();
75
- return bearer ? decodeBearer(bearer) : null;
98
+ const viewer = await resolveRequestViewer();
99
+ if (!viewer)
100
+ return null;
101
+ // Project to the documented shape so layouts that pass the whole object to a
102
+ // client provider don't serialize server-side fields to the browser.
103
+ const { workspaceId, clientId, internalUserId, companyId, notificationId, instanceId, sessionId } = viewer.identity;
104
+ return { workspaceId, clientId, internalUserId, companyId, notificationId, instanceId, sessionId };
76
105
  }
77
106
  /**
78
107
  * Thrown when a session token is required but missing, or was supplied but
@@ -91,6 +120,24 @@ export class TokenInvalidError extends Error {
91
120
  this.name = 'TokenInvalidError';
92
121
  }
93
122
  }
123
+ /**
124
+ * Thrown when the platform could not be reached to verify the viewer's bearer
125
+ * (outage, throttle, timeout). It says nothing about the bearer, so it is NOT a
126
+ * token-invalid error: do not re-exchange on it (that would spend a single-use
127
+ * session token for nothing) and do not render a signed-out state. Render a
128
+ * retry state instead. Distinct `name`/`code` from TokenInvalidError on purpose.
129
+ *
130
+ * Code you write follows that. The Server Action helper cannot: production redacts
131
+ * the error across the boundary, so it renews on any failure and instead bounds
132
+ * how often (one renewal per window per tab).
133
+ */
134
+ export class AuthUnavailableError extends Error {
135
+ code = 'AUTH_UNAVAILABLE';
136
+ constructor(message = 'Assembly could not verify the session right now') {
137
+ super(message);
138
+ this.name = 'AuthUnavailableError';
139
+ }
140
+ }
94
141
  export async function getAssembly(input) {
95
142
  const token = typeof input === 'string' ? input : input?.token;
96
143
  // Exchange enabled ⇒ this is a bearer-capable app, and it authenticates via
@@ -104,31 +151,25 @@ export async function getAssembly(input) {
104
151
  // after() and pass it through explicitly as `input.token`; this honors that
105
152
  // pre-resolved bearer instead of re-deriving it from request scope. The
106
153
  // common case (no explicit token) is unchanged: derive from the request.
154
+ // Either way the identity is the one the platform verified for the bearer.
107
155
  if (isSessionExchangeEnabled()) {
108
- const bearer = token ?? (await resolveRequestBearer());
109
- if (!bearer) {
110
- throw new TokenInvalidError('Assembly bearer is required');
156
+ let viewer;
157
+ if (token) {
158
+ const result = await verifyBearer(token);
159
+ if (result.ok) {
160
+ viewer = { bearer: token, identity: result.identity };
161
+ }
162
+ else if (result.reason === 'unavailable') {
163
+ throw new AuthUnavailableError();
164
+ }
111
165
  }
112
- const assembly = await assemblyApi({ bearer, baseUrl: trustedBaseUrl });
113
- const payload = await assembly.getTokenPayload?.();
114
- if (!payload?.workspaceId) {
115
- throw new TokenInvalidError();
166
+ else {
167
+ viewer = await resolveRequestViewer();
116
168
  }
117
- // The published SDK's bearer-mode payload maps only the identity claims,
118
- // so overlay the embed-context claims here to keep getTokenPayload()'s
119
- // documented contract (payload.notificationId marks the Notification
120
- // Center surface) intact for exchange-capable apps. Becomes a harmless
121
- // no-op once the SDK maps these claims natively.
122
- const session = decodeBearer(bearer);
123
- if (session?.notificationId || session?.instanceId) {
124
- const merged = {
125
- ...payload,
126
- notificationId: session.notificationId,
127
- instanceId: session.instanceId,
128
- };
129
- assembly.getTokenPayload = () => Promise.resolve(merged);
169
+ if (!viewer) {
170
+ throw new TokenInvalidError(token ? undefined : 'Assembly bearer is required');
130
171
  }
131
- return assembly;
172
+ return getBearerAssembly(viewer.bearer, viewer.identity);
132
173
  }
133
174
  // Exchange disabled (legacy apps built before this feature, or a rollback):
134
175
  // the raw session token is the credential and is sent as X-API-Key, exactly
@@ -198,13 +239,35 @@ export function afterAssembly(task, options) {
198
239
  function tokenExpiredResponse() {
199
240
  return NextResponse.json({ error: 'Unauthorized', code: 'token_expired' }, { status: 401, headers: { [TOKEN_EXPIRED_HEADER]: '1' } });
200
241
  }
242
+ export const AUTH_UNAVAILABLE_CODE = 'auth_unavailable';
243
+ // 503 with deliberately NO token-expired marker: the bearer was never judged, so
244
+ // authFetch must not re-exchange (which would spend a single-use session token)
245
+ // and the client should retry the same request instead.
246
+ export function authUnavailableResponse() {
247
+ return NextResponse.json({ error: 'Service unavailable', code: AUTH_UNAVAILABLE_CODE }, { status: 503 });
248
+ }
201
249
  // Builds a bearer-only SDK client. The SDK's native `bearer` option sends
202
250
  // Authorization: Bearer on every request — generated service methods and fetch
203
- // alike — and, with no apiKey supplied, sends NO X-API-Key. Identity comes from
204
- // the bearer's own signed claims (getTokenPayload() reads them); the API origin
205
- // comes from the trusted build-env baseUrl, never a token/bearer claim.
206
- async function getBearerAssembly(bearer) {
207
- return assemblyApi({ bearer, baseUrl: trustedBaseUrl });
251
+ // alike — and, with no apiKey supplied, sends NO X-API-Key. The API origin comes
252
+ // from the trusted build-env baseUrl, never a token/bearer claim. When the
253
+ // caller has a verified identity, getTokenPayload() answers with it rather than
254
+ // with the SDK's read of the bearer's claims.
255
+ async function getBearerAssembly(bearer, identity) {
256
+ const assembly = await assemblyApi({ bearer, baseUrl: trustedBaseUrl });
257
+ if (identity) {
258
+ // Includes the embed-context ids the SDK's bearer-mode payload omits, so
259
+ // payload.notificationId still marks the Notification Center surface.
260
+ const payload = {
261
+ workspaceId: identity.workspaceId,
262
+ clientId: identity.clientId,
263
+ companyId: identity.companyId,
264
+ internalUserId: identity.internalUserId,
265
+ notificationId: identity.notificationId,
266
+ instanceId: identity.instanceId,
267
+ };
268
+ assembly.getTokenPayload = () => Promise.resolve(payload);
269
+ }
270
+ return assembly;
208
271
  }
209
272
  /**
210
273
  * OFFLINE / BACKGROUND ONLY — cron jobs, webhook handlers, and any other code
@@ -250,23 +313,30 @@ export async function getOfflineAssembly() {
250
313
  }
251
314
  // Returns a request-scoped SDK client authenticated for the current user.
252
315
  //
253
- // Exchange enabled: bearer-only. A valid bearer (proxy header or cookie) yields
254
- // a client that carries Authorization: Bearer on every request; a missing or
255
- // undecodable bearer yields a 401 NextResponse so the client re-exchanges via
256
- // the x-assembly-token-expired convention. There is no session-token fallback.
316
+ // Exchange enabled: bearer-only. A bearer the platform verifies (proxy header or
317
+ // cookie) yields a client that carries Authorization: Bearer on every request; a
318
+ // missing or rejected bearer yields a 401 NextResponse so the client re-exchanges
319
+ // via the x-assembly-token-expired convention, and a bearer that could not be
320
+ // verified yields a 503 without that marker. There is no session-token fallback.
257
321
  // Exchange disabled: the legacy session-token (X-API-Key) path.
258
322
  export async function getAssemblyForRequest(req) {
259
323
  if (isSessionExchangeEnabled()) {
260
324
  // Bearer-only: the proxy-forwarded header on first load, else the HttpOnly
261
- // cookie. Its signed claims carry identity for SDK init. No bearer (or an
262
- // undecodable/expired one) ⇒ 401 marker so the client re-exchanges; there
263
- // is no X-API-Key / session-token fallback for a bearer-capable app.
325
+ // cookie. No bearer (or a rejected/expired one) ⇒ 401 marker so the client
326
+ // re-exchanges; there is no X-API-Key / session-token fallback for a
327
+ // bearer-capable app.
264
328
  const bearer = req.headers.get(BEARER_HEADER_NAME) ??
265
329
  req.cookies.get(BEARER_COOKIE_NAME)?.value;
266
- if (!bearer || !decodeBearer(bearer)) {
330
+ if (!bearer) {
267
331
  return tokenExpiredResponse();
268
332
  }
269
- return getBearerAssembly(bearer);
333
+ const result = await verifyBearer(bearer);
334
+ if (!result.ok) {
335
+ return result.reason === 'rejected'
336
+ ? tokenExpiredResponse()
337
+ : authUnavailableResponse();
338
+ }
339
+ return getBearerAssembly(bearer, result.identity);
270
340
  }
271
341
  // Legacy (exchange disabled): authenticate from the session-token header,
272
342
  // sent as X-API-Key. Missing token ⇒ 401 marker.
package/dist/auth.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { NextResponse } from 'next/server';
2
- import { getAssembly } from './assembly.js';
3
- import { BEARER_COOKIE_NAME, BEARER_HEADER_NAME, decodeBearer, isSessionExchangeEnabled, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
2
+ import { authUnavailableResponse, getAssembly } from './assembly.js';
3
+ import { BEARER_COOKIE_NAME, BEARER_HEADER_NAME, isSessionExchangeEnabled, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
4
+ import { verifyBearer } from './verify-bearer.js';
4
5
  // Detect an expired/undecryptable-token error from the SDK by shape rather than
5
6
  // by importing a concrete class — the foundation builds against whatever SDK
6
7
  // version is published, which may not export the error type.
@@ -14,26 +15,35 @@ function tokenExpiredResponse() {
14
15
  return NextResponse.json({ error: 'Unauthorized', code: 'token_expired' }, { status: 401, headers: { [TOKEN_EXPIRED_HEADER]: '1' } });
15
16
  }
16
17
  // Resolves the current viewer's identity for a route handler.
17
- // Exchange on: bearer-only — decodes the bearer (proxy-forwarded header on first
18
- // load, else the HttpOnly cookie); a missing/undecodable bearer returns a 401
19
- // with the x-assembly-token-expired marker so the client re-exchanges. There is
20
- // no session-token fallback for a bearer-capable app.
18
+ // Exchange on: bearer-only — verifies the bearer with the platform (proxy-
19
+ // forwarded header on first load, else the HttpOnly cookie) and builds the
20
+ // payload from the verified identity, never from the bearer's claims. A
21
+ // missing/rejected bearer returns a 401 with the x-assembly-token-expired marker
22
+ // so the client re-exchanges; a bearer that could not be verified returns a 503
23
+ // without the marker, so nothing re-exchanges. There is no session-token
24
+ // fallback for a bearer-capable app.
21
25
  // Exchange off (legacy): reads and decodes the x-assembly-token header.
22
26
  export async function requireAuth(req) {
23
27
  if (isSessionExchangeEnabled()) {
24
28
  const bearer = req.headers.get(BEARER_HEADER_NAME) ??
25
29
  req.cookies.get(BEARER_COOKIE_NAME)?.value;
26
- const session = bearer ? decodeBearer(bearer) : null;
27
- if (!session) {
30
+ if (!bearer) {
28
31
  return tokenExpiredResponse();
29
32
  }
33
+ const result = await verifyBearer(bearer);
34
+ if (!result.ok) {
35
+ return result.reason === 'rejected'
36
+ ? tokenExpiredResponse()
37
+ : authUnavailableResponse();
38
+ }
39
+ const { identity } = result;
30
40
  return {
31
- workspaceId: session.workspaceId,
32
- clientId: session.clientId,
33
- internalUserId: session.internalUserId,
34
- companyId: session.companyId,
35
- notificationId: session.notificationId,
36
- instanceId: session.instanceId,
41
+ workspaceId: identity.workspaceId,
42
+ clientId: identity.clientId,
43
+ internalUserId: identity.internalUserId,
44
+ companyId: identity.companyId,
45
+ notificationId: identity.notificationId,
46
+ instanceId: identity.instanceId,
37
47
  };
38
48
  }
39
49
  const token = req.headers.get('x-assembly-token');
@@ -86,4 +86,5 @@ export declare class TtlCache<V> {
86
86
  constructor(maxEntries: number);
87
87
  get(key: string): V | undefined;
88
88
  set(key: string, value: V, ttlMs: number): void;
89
+ delete(key: string): void;
89
90
  }
@@ -139,4 +139,7 @@ export class TtlCache {
139
139
  }
140
140
  this.entries.set(key, { value, expiresAt: Date.now() + ttlMs });
141
141
  }
142
+ delete(key) {
143
+ this.entries.delete(key);
144
+ }
142
145
  }
package/dist/server.d.ts CHANGED
@@ -3,6 +3,7 @@ export * from './assembly.js';
3
3
  export * from './auth.js';
4
4
  export * from './client-access.js';
5
5
  export * from './mint-bearer.js';
6
+ export * from './verify-bearer.js';
6
7
  export * from './nango.js';
7
8
  export * from './airtable.js';
8
9
  export * from './gmail.js';
package/dist/server.js CHANGED
@@ -7,6 +7,7 @@ export * from './assembly.js';
7
7
  export * from './auth.js';
8
8
  export * from './client-access.js';
9
9
  export * from './mint-bearer.js';
10
+ export * from './verify-bearer.js';
10
11
  export * from './nango.js';
11
12
  export * from './airtable.js';
12
13
  export * from './gmail.js';
@@ -1,15 +1,13 @@
1
1
  // Stateless bearer helpers for the token-exchange flow (AI-260).
2
2
  // The proxy exchanges the session token once and sets the returned bearer as an
3
3
  // HttpOnly cookie. That bearer is a signed JWT carrying the caller's identity as
4
- // claims, so requireAuth()/getAssemblyForRequest() resolve identity by DECODING
5
- // the cookie — no server-side cache. A cache can't work here anyway: on Vercel
6
- // the middleware that would populate it and the route handlers that read it run
7
- // in separate runtimes/instances and never share memory.
4
+ // claims. decodeBearer reads those claims without any network call, so it serves
5
+ // as a cheap precheck (shape, expiry, workspace claim); identity itself comes
6
+ // from verifyBearer, which asks the platform. Each runtime instance verifies a
7
+ // given bearer once per cache window, because on Vercel the middleware and the
8
+ // route handlers run in separate instances that never share memory.
8
9
  //
9
- // decodeBearer does NOT verify the signature. The platform-api authorizer
10
- // re-validates the token's signature and scope on every real API call, which is
11
- // the security boundary; these decoded claims are advisory to the app (SDK init,
12
- // early authz rejects) and never the sole gate.
10
+ // decodeBearer is a no-network shape and expiry precheck.
13
11
  export const BEARER_COOKIE_NAME = 'x-assembly-bearer';
14
12
  export const BEARER_TTL_MS = 60 * 60 * 1_000; // 60 minutes, matches bearer TTL
15
13
  // The only statuses that prove the session token ITSELF is spent or invalid:
@@ -176,11 +174,10 @@ function decodeBase64UrlJson(segment) {
176
174
  const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
177
175
  return JSON.parse(new TextDecoder().decode(bytes));
178
176
  }
179
- // Decodes the identity claims from a bearer JWT. Signature is NOT verified here
180
- // (the platform-api authorizer re-validates it on every real call — the true
181
- // boundary); these claims are advisory for SDK init and early authz. Returns
182
- // null when the token is malformed, expired, or carries no workspace — callers
183
- // then return 401 / re-exchange.
177
+ // Reads the bearer's claims locally (shape and expiry precheck only). Identity
178
+ // for authorization comes from verifyBearer. Returns null when the token is
179
+ // malformed, expired, or carries no workspace — callers then return 401 /
180
+ // re-exchange.
184
181
  export function decodeBearer(bearer) {
185
182
  const parts = bearer.split('.');
186
183
  if (parts.length !== 3)
@@ -0,0 +1,29 @@
1
+ export interface VerifiedIdentity {
2
+ workspaceId: string;
3
+ clientId?: string;
4
+ companyId?: string;
5
+ internalUserId?: string;
6
+ notificationId?: string;
7
+ instanceId?: string;
8
+ sessionId?: string;
9
+ callerAppId?: string;
10
+ installerUserId?: string;
11
+ tokenKind?: string;
12
+ /** Epoch SECONDS at which the verified bearer expires. */
13
+ expiresAt: number;
14
+ }
15
+ export type VerifyResult = {
16
+ ok: true;
17
+ identity: VerifiedIdentity;
18
+ } | {
19
+ ok: false;
20
+ reason: 'rejected' | 'unavailable';
21
+ };
22
+ export declare const VERIFY_PATH = "/v1/session/verify";
23
+ export declare const VIEWER_BEARER_HEADER = "X-Assembly-Viewer-Bearer";
24
+ export declare const VERIFY_OUTCOME_EVENT = "assembly.verify_outcome";
25
+ /**
26
+ * Resolves a viewer bearer to the identity the platform vouches for. Identity
27
+ * must come from here, never from the bearer's decoded claims.
28
+ */
29
+ export declare function verifyBearer(bearer: string): Promise<VerifyResult>;
@@ -0,0 +1,191 @@
1
+ import { OpenAPI } from '@assembly-js/node-sdk';
2
+ import { TtlCache } from './resource-resolution.js';
3
+ import { decodeBearer } from './session-exchange.js';
4
+ export const VERIFY_PATH = '/v1/session/verify';
5
+ export const VIEWER_BEARER_HEADER = 'X-Assembly-Viewer-Bearer';
6
+ export const VERIFY_OUTCOME_EVENT = 'assembly.verify_outcome';
7
+ // A verify sits on every authenticated request's critical path, so it gets far
8
+ // less rope than the exchange.
9
+ const VERIFY_TIMEOUT_MS = 2_000;
10
+ // Bounds how long a verdict can outlive a revocation on this instance.
11
+ const POSITIVE_TTL_MS = 60_000;
12
+ // Short enough that a bearer rejected during a transient state recovers quickly,
13
+ // long enough to absorb a burst of requests carrying the same bad bearer.
14
+ const REJECTED_TTL_MS = 10_000;
15
+ const STALE_GRACE_MS = 5 * 60_000;
16
+ // Separate bounds so rejected entries can never evict positive ones.
17
+ const MAX_POSITIVE_ENTRIES = 1_000;
18
+ const MAX_REJECTED_ENTRIES = 1_000;
19
+ // The only 401 codes that prove the bearer itself is refused; any other 401
20
+ // (a gateway or authorizer response) says nothing about it.
21
+ const REJECTION_CODES = new Set(['invalid_token', 'token_expired']);
22
+ // One cache per runtime instance: instances never share memory, so each
23
+ // verifies a given bearer once per TTL.
24
+ const positiveCache = new TtlCache(MAX_POSITIVE_ENTRIES);
25
+ const rejectedCache = new TtlCache(MAX_REJECTED_ENTRIES);
26
+ // The last verdict the platform gave, kept for a short grace period (never past
27
+ // the bearer's own expiry). Only consulted when the platform cannot be reached,
28
+ // so a blip does not take down viewers it had just vouched for; an explicit
29
+ // rejection evicts it. Bounded tightly because during an outage a revocation
30
+ // cannot be seen.
31
+ const lastGoodCache = new TtlCache(MAX_POSITIVE_ENTRIES);
32
+ const inFlight = new Map();
33
+ // Raw bearers are never held in memory beyond the call; the digest is the key.
34
+ async function hashBearer(bearer) {
35
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(bearer));
36
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
37
+ }
38
+ function optionalString(value) {
39
+ return typeof value === 'string' && value !== '' ? value : undefined;
40
+ }
41
+ function parseIdentity(body) {
42
+ if (!body || typeof body !== 'object')
43
+ return null;
44
+ const b = body;
45
+ const workspaceId = optionalString(b.workspaceId);
46
+ if (!workspaceId)
47
+ return null;
48
+ if (typeof b.expiresAt !== 'number' || !Number.isFinite(b.expiresAt)) {
49
+ return null;
50
+ }
51
+ return {
52
+ workspaceId,
53
+ clientId: optionalString(b.clientId),
54
+ companyId: optionalString(b.companyId),
55
+ internalUserId: optionalString(b.internalUserId),
56
+ notificationId: optionalString(b.notificationId),
57
+ instanceId: optionalString(b.instanceId),
58
+ sessionId: optionalString(b.sessionId),
59
+ callerAppId: optionalString(b.callerAppId),
60
+ installerUserId: optionalString(b.installerUserId),
61
+ tokenKind: optionalString(b.tokenKind),
62
+ expiresAt: b.expiresAt,
63
+ };
64
+ }
65
+ function logOutcome(outcome, startedAt) {
66
+ console.log(JSON.stringify({
67
+ event_type: VERIFY_OUTCOME_EVENT,
68
+ outcome,
69
+ duration_ms: Date.now() - startedAt,
70
+ }));
71
+ }
72
+ // Callers get a copy so a mutation can never reach the cached identity.
73
+ function frozenOk(identity) {
74
+ return { ok: true, identity: Object.freeze({ ...identity }) };
75
+ }
76
+ const UNUSABLE = {
77
+ result: { ok: false, reason: 'unavailable' },
78
+ platformDown: false,
79
+ };
80
+ const DOWN = {
81
+ result: { ok: false, reason: 'unavailable' },
82
+ platformDown: true,
83
+ };
84
+ async function fetchVerdict(bearer) {
85
+ const apiKey = process.env.ASSEMBLY_API_KEY;
86
+ if (!apiKey)
87
+ return UNUSABLE;
88
+ const base = (process.env.ASSEMBLY_API_BASE_URL || OpenAPI.BASE).replace(/\/$/, '');
89
+ const headers = {
90
+ 'X-API-Key': apiKey,
91
+ [VIEWER_BEARER_HEADER]: bearer,
92
+ };
93
+ const controller = new AbortController();
94
+ const timeoutId = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
95
+ try {
96
+ let resp;
97
+ try {
98
+ resp = await fetch(`${base}${VERIFY_PATH}`, {
99
+ headers,
100
+ signal: controller.signal,
101
+ });
102
+ }
103
+ catch {
104
+ // Network failure or the timeout.
105
+ return DOWN;
106
+ }
107
+ if (resp.status >= 500)
108
+ return DOWN;
109
+ try {
110
+ if (resp.status === 200) {
111
+ const identity = parseIdentity(await resp.json());
112
+ return identity
113
+ ? { result: { ok: true, identity }, platformDown: false }
114
+ : UNUSABLE;
115
+ }
116
+ if (resp.status === 401) {
117
+ const body = (await resp.json());
118
+ if (typeof body?.code === 'string' && REJECTION_CODES.has(body.code)) {
119
+ return {
120
+ result: { ok: false, reason: 'rejected' },
121
+ platformDown: false,
122
+ };
123
+ }
124
+ }
125
+ }
126
+ catch {
127
+ // A non-JSON body (gateway HTML).
128
+ }
129
+ return UNUSABLE;
130
+ }
131
+ finally {
132
+ clearTimeout(timeoutId);
133
+ }
134
+ }
135
+ async function verifyUncached(bearer, key, startedAt) {
136
+ const { result, platformDown } = await fetchVerdict(bearer);
137
+ if (result.ok) {
138
+ // Never outlive the bearer itself.
139
+ const ttl = Math.min(POSITIVE_TTL_MS, result.identity.expiresAt * 1_000 - Date.now());
140
+ positiveCache.set(key, result.identity, ttl);
141
+ lastGoodCache.set(key, result.identity, Math.min(STALE_GRACE_MS, result.identity.expiresAt * 1_000 - Date.now()));
142
+ logOutcome('miss', startedAt);
143
+ return frozenOk(result.identity);
144
+ }
145
+ if (result.reason === 'rejected') {
146
+ rejectedCache.set(key, true, REJECTED_TTL_MS);
147
+ lastGoodCache.delete(key);
148
+ }
149
+ else if (platformDown) {
150
+ const lastGood = lastGoodCache.get(key);
151
+ if (lastGood) {
152
+ logOutcome('stale', startedAt);
153
+ return frozenOk(lastGood);
154
+ }
155
+ }
156
+ logOutcome(result.reason, startedAt);
157
+ return result;
158
+ }
159
+ /**
160
+ * Resolves a viewer bearer to the identity the platform vouches for. Identity
161
+ * must come from here, never from the bearer's decoded claims.
162
+ */
163
+ export async function verifyBearer(bearer) {
164
+ const startedAt = Date.now();
165
+ // The decode is a no-network precheck: shape, expiry and workspace claim.
166
+ if (!decodeBearer(bearer)) {
167
+ logOutcome('rejected', startedAt);
168
+ return { ok: false, reason: 'rejected' };
169
+ }
170
+ const key = await hashBearer(bearer);
171
+ const cached = positiveCache.get(key);
172
+ if (cached && cached.expiresAt * 1_000 > Date.now()) {
173
+ logOutcome('hit', startedAt);
174
+ return frozenOk(cached);
175
+ }
176
+ if (rejectedCache.get(key)) {
177
+ logOutcome('rejected', startedAt);
178
+ return { ok: false, reason: 'rejected' };
179
+ }
180
+ // Concurrent requests carrying one bearer share a single platform call.
181
+ const pending = inFlight.get(key);
182
+ if (pending) {
183
+ const shared = await pending;
184
+ return shared.ok ? frozenOk(shared.identity) : shared;
185
+ }
186
+ const request = verifyUncached(bearer, key, startedAt).finally(() => {
187
+ inFlight.delete(key);
188
+ });
189
+ inFlight.set(key, request);
190
+ return request;
191
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.39",
3
+ "version": "0.0.40",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -256,7 +256,7 @@ Every event also lands in the workspace's audit log, where a workspace admin rea
256
256
 
257
257
  | Header | Value |
258
258
  |---|---|
259
- | `X-Assembly-Actor-Id` | the viewer's id from `requireAuth` — `internalUserId`, else `clientId` |
259
+ | `X-Assembly-Actor-Id` | the viewer's id from `requireAuth` (the platform-verified identity) — `internalUserId`, else `clientId` |
260
260
  | `X-Assembly-Source` | `web` when a person did this in the app's UI |
261
261
  | `X-Assembly-Client-IP` | the request's `x-forwarded-for` |
262
262
  | `X-Assembly-User-Agent` | the request's `user-agent` |
@@ -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.
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.
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
 
@@ -13,7 +13,7 @@ import { requireAuth, requireCompanyScope, handleRouteError } from '@assembly-js
13
13
 
14
14
  ## How the helpers work
15
15
 
16
- **`requireAuth(req)`** decodes the viewer's identity from the bearer (read from the request cookie — no `x-assembly-token` header) and returns it. Returns a 401 `NextResponse` (with a `{ code: 'token_expired' }` body and an `x-assembly-token-expired: 1` header) when the bearer is missing or expired — `authFetch` detects this and re-exchanges + retries automatically.
16
+ **`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.
17
17
 
18
18
  **`requireCompanyScope(identity, resource, assembly?)`** asserts the viewer can access a resource scoped to a company **or to a client**:
19
19
 
@@ -11,7 +11,7 @@ Your job is to follow three rules.
11
11
 
12
12
  Never decode a token yourself and never call `AssemblyBridge.sessionToken.*` — that API is the foundation's, not yours.
13
13
 
14
- Server-side, `getSessionIdentity()` decodes the viewer from the bearer (no token, no API call). It returns `null` when unauthenticated:
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
15
 
16
16
  ```ts
17
17
  import { getSessionIdentity } from '@assembly-js/app-foundation/server';