@assembly-js/app-foundation 0.0.41 → 0.0.43

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/dist/proxy.js ADDED
@@ -0,0 +1,669 @@
1
+ // The app's root proxy (Next middleware): first-load session-token exchange, the
2
+ // bearer cookie, the CSRF gate, and the cookieless fallback. It lives in the
3
+ // package rather than in each app because it is security-critical code an agent
4
+ // must never re-author; an app's proxy.ts only calls createAssemblyProxy and
5
+ // adds its own extras through onForward.
6
+ import { assemblyApi } from '@assembly-js/node-sdk';
7
+ import { NextResponse } from 'next/server';
8
+ import { APP_CSRF_HEADER, BEARER_COOKIE_NAME, BEARER_HEADER_NAME, CLIENT_BEARER_HEADER, decodeBearer, isDocumentRequest, isSessionExchangeEnabled, logCookieOutcome, serializeBearerCookie, serializeCookieProbe, } from './session-exchange.js';
9
+ import { CLIENT_FLAGS_COOKIE_NAME } from './origin-bootstrap.js';
10
+ import { handlePaintedBeacon, isPaintedBeaconPath } from './paint-beacon.js';
11
+ import { fetchSessionFlags, mintBearerFromAppKey, mintBearerFromSessionToken, } from './mint-bearer.js';
12
+ // Freshness knob for the client-readable flags cookie: how long a flag flip
13
+ // can take to reach an app (at its next document load) without a re-mint.
14
+ const FLAGS_COOKIE_TTL_SECONDS = 300;
15
+ // Trusted API origin for this stack (build-env supplied). Passed to the SDK so
16
+ // tokenless/bearer calls reach the right stack; empty in prod where the SDK
17
+ // default is correct.
18
+ const proxyBaseUrl = process.env.ASSEMBLY_API_BASE_URL || undefined;
19
+ // Decrypts a session token into a raw SDK client for the proxy's own use
20
+ // (reading the payload, driving the exchange). Deliberately bypasses the
21
+ // foundation's strict getAssembly(): the proxy runs in edge middleware where
22
+ // next/headers isn't available, and it must be able to decrypt even a token
23
+ // that strict auth would later reject — that rejection is the app's job, not
24
+ // the exchange's.
25
+ async function decryptSessionToken(token) {
26
+ return assemblyApi({
27
+ apiKey: process.env.ASSEMBLY_API_KEY ?? '',
28
+ token,
29
+ baseUrl: proxyBaseUrl,
30
+ });
31
+ }
32
+ const STATIC_FRAME_ANCESTORS = [
33
+ "'self'",
34
+ 'https://*.assembly.com',
35
+ 'https://*.assembly-staging.com',
36
+ 'https://*.myassembly.com',
37
+ 'https://*.myassembly-staging.com',
38
+ 'https://*.copilot.app',
39
+ 'https://*.copilot-staging.app',
40
+ 'http://localhost:*',
41
+ 'http://127.0.0.1:*',
42
+ ];
43
+ // Short TTL for workspace theme/label data — independent of bearer session TTL.
44
+ const WORKSPACE_TTL_MS = 5 * 60 * 1_000;
45
+ // Bound for the non-destructive workspace-theme fetch: on timeout the render
46
+ // just falls back to neutral branding and re-attempts next request. Generous
47
+ // enough to survive a cold platform-api lambda / normal network latency.
48
+ const FETCH_TIMEOUT_MS = 10_000;
49
+ const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
50
+ // Identity/branding headers the proxy sets from its own resolution and the
51
+ // layout reads. Stripped from every inbound request so they can't be spoofed.
52
+ const PROXY_OWNED_IDENTITY_HEADERS = [
53
+ 'x-assembly-is-client',
54
+ 'x-assembly-color-accent',
55
+ 'x-assembly-font',
56
+ 'x-assembly-label-client-one',
57
+ 'x-assembly-label-client-many',
58
+ 'x-assembly-label-company-one',
59
+ 'x-assembly-label-company-many',
60
+ 'x-assembly-distinct-id',
61
+ 'x-assembly-workspace-id',
62
+ ];
63
+ function stripProxyOwnedHeaders(headers) {
64
+ headers.delete(BEARER_HEADER_NAME);
65
+ headers.delete(CLIENT_BEARER_HEADER);
66
+ for (const header of PROXY_OWNED_IDENTITY_HEADERS) {
67
+ headers.delete(header);
68
+ }
69
+ }
70
+ // Neutral branding for the exchange path when the workspace fetch fails but the
71
+ // bearer exchange itself succeeds: the session token is single-use, so we must
72
+ // keep the minted bearer and return neutral branding rather than drop it. The
73
+ // legacy path instead returns null and caches nothing (pre-AI-260 behavior).
74
+ const NO_BRANDING = {
75
+ portalUrl: null,
76
+ colorAccent: null,
77
+ font: null,
78
+ labelClientOne: 'Client',
79
+ labelClientMany: 'Clients',
80
+ labelCompanyOne: 'Company',
81
+ labelCompanyMany: 'Companies',
82
+ };
83
+ const MAX_WORKSPACE_THEME_CACHE_SIZE = 500;
84
+ const MAX_LEGACY_TOKEN_CACHE_SIZE = 1_000;
85
+ // Workspace theme cache keyed by workspace ID (NOT by any credential). Shared
86
+ // across users in the same portal; a miss just re-fetches, so a cold serverless
87
+ // instance degrades to one extra theme read, never to wrong auth.
88
+ const workspaceThemeCache = new Map();
89
+ // Bearer-first workspace branding read with an app-scoped bearer fallback.
90
+ // New apps run with session exchange on; a viewer bearer can fail
91
+ // retrieveWorkspace while auth still succeeds (client-scoped tokens often
92
+ // cannot read workspace branding). assemblyApi({ apiKey }) throws on current
93
+ // SDK versions outside local/staging ("Unable to authorize Assembly SDK"), so
94
+ // the old key-only fallback was a landmine: any viewer miss constructed that
95
+ // client and 500'd the render. Mint an app bearer instead — the same
96
+ // primitive getOfflineAssembly uses — then read with it.
97
+ //
98
+ // Branding is cosmetic and this runs AFTER the single-use session token was
99
+ // consumed by the mint, so nothing here may throw: an uncaught throw fails the
100
+ // whole SSR render as a 500, the Set-Cookie is lost, and the burned token makes
101
+ // every retry render the null-identity fallback. Client construction sits
102
+ // inside the same catch as its read because assemblyApi itself throws when it
103
+ // cannot derive an auth key.
104
+ async function fetchWorkspaceThemeWithBearer(bearer, workspaceId) {
105
+ try {
106
+ const client = await assemblyApi({ bearer, baseUrl: proxyBaseUrl });
107
+ return await fetchWorkspaceTheme(client, workspaceId);
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
113
+ async function fetchWorkspaceThemeForIdentity(workspaceId, bearer) {
114
+ if (bearer) {
115
+ const fromViewer = await fetchWorkspaceThemeWithBearer(bearer, workspaceId);
116
+ if (fromViewer)
117
+ return fromViewer;
118
+ }
119
+ // Viewer miss: mint an app-scoped bearer. mintBearerFromAppKey already
120
+ // fails closed (null), but the path must stay throw-proof even if a mock
121
+ // or future change surfaces an exception.
122
+ try {
123
+ const minted = await mintBearerFromAppKey();
124
+ if (!minted?.bearer)
125
+ return null;
126
+ return await fetchWorkspaceThemeWithBearer(minted.bearer, workspaceId);
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ }
132
+ async function fetchWorkspaceTheme(assembly, workspaceId) {
133
+ const now = Date.now();
134
+ const cached = workspaceThemeCache.get(workspaceId);
135
+ if (cached && cached.expiresAt > now)
136
+ return cached;
137
+ let timeoutId;
138
+ try {
139
+ const timeoutPromise = new Promise((_, reject) => {
140
+ timeoutId = setTimeout(() => reject(new Error('retrieveWorkspace timeout')), FETCH_TIMEOUT_MS);
141
+ });
142
+ const workspace = await Promise.race([assembly.retrieveWorkspace(), timeoutPromise]);
143
+ clearTimeout(timeoutId);
144
+ const rawPortalUrl = workspace.portalUrl ?? null;
145
+ const theme = {
146
+ portalUrl: rawPortalUrl ? rawPortalUrl.replace(/^https?:\/\//, '') : null,
147
+ colorAccent: workspace.colorAccent ?? null,
148
+ font: workspace.font ?? null,
149
+ labelClientOne: workspace.labels?.individualTerm || 'Client',
150
+ labelClientMany: workspace.labels?.individualTermPlural || 'Clients',
151
+ labelCompanyOne: workspace.labels?.groupTerm || 'Company',
152
+ labelCompanyMany: workspace.labels?.groupTermPlural || 'Companies',
153
+ expiresAt: now + WORKSPACE_TTL_MS,
154
+ };
155
+ if (workspaceThemeCache.size >= MAX_WORKSPACE_THEME_CACHE_SIZE) {
156
+ const oldest = workspaceThemeCache.keys().next().value;
157
+ if (oldest !== undefined)
158
+ workspaceThemeCache.delete(oldest);
159
+ }
160
+ workspaceThemeCache.set(workspaceId, theme);
161
+ return theme;
162
+ }
163
+ catch {
164
+ // Fetch failed — cache nothing and return null so the next request re-attempts,
165
+ // exactly as the pre-AI-260 proxy did. We never persist placeholder branding.
166
+ clearTimeout(timeoutId);
167
+ return null;
168
+ }
169
+ }
170
+ // Performs the server-side token exchange on the first request from the iframe:
171
+ // trades the session token for a short-lived bearer (via mintBearerFromSessionToken)
172
+ // and fetches the workspace theme WITH that bearer. Returns the identity + theme
173
+ // for THIS response's headers/cookie only — nothing is cached per-bearer; every
174
+ // later request re-derives identity by decoding the bearer cookie.
175
+ async function resolveAndExchange(token, cookieSessionId) {
176
+ const minted = await mintBearerFromSessionToken(token, { cookieSessionId });
177
+ // No bearer: undecodable, not exchange-capable (no tokenId), a consumed/
178
+ // replayed/expired single-use token, or an exchange that could not be
179
+ // completed. Fail closed — a bearer-capable app never renders from a raw
180
+ // token. The reason travels with the rejection because it decides whether a
181
+ // warm cookie may answer for this token — as does the viewer verdict the
182
+ // exchange returns when it was told which session the cookie came from.
183
+ if (!minted.ok) {
184
+ return {
185
+ kind: 'reject',
186
+ reason: minted.reason,
187
+ rejectedViewer: minted.rejectedViewer,
188
+ };
189
+ }
190
+ const { bearer, bearerExpiresAt, session } = minted;
191
+ // Theme is cosmetic: a viewer-bearer miss falls back to an app-scoped mint
192
+ // (or NO_BRANDING) inside fetchWorkspaceThemeForIdentity. The viewer bearer
193
+ // is kept regardless — the single-use session token was already consumed.
194
+ const theme = await fetchWorkspaceThemeForIdentity(session.workspaceId, bearer);
195
+ return { kind: 'exchanged', session, theme, bearer, bearerExpiresAt };
196
+ }
197
+ // Legacy resolve path for session tokens that do not carry a tokenId (older
198
+ // SDK versions). Keeps behaviour identical to pre-AI-260 for those callers.
199
+ const legacyTokenCache = new Map();
200
+ // Deduplicates concurrent first-load requests that carry the same session token
201
+ // so only one /v1/session call is made per token.
202
+ const exchangeInFlight = new Map();
203
+ async function resolveLegacyToken(token) {
204
+ const now = Date.now();
205
+ const cached = legacyTokenCache.get(token);
206
+ if (cached && cached.expiresAt > now)
207
+ return cached;
208
+ if (cached)
209
+ legacyTokenCache.delete(token);
210
+ let assembly;
211
+ try {
212
+ assembly = await decryptSessionToken(token);
213
+ }
214
+ catch {
215
+ return null;
216
+ }
217
+ let workspaceId = null;
218
+ let clientId;
219
+ let internalUserId;
220
+ let companyId;
221
+ if (typeof assembly.getTokenPayload === 'function') {
222
+ try {
223
+ const p = await assembly.getTokenPayload();
224
+ workspaceId = p.workspaceId ?? null;
225
+ clientId = p.clientId || undefined;
226
+ internalUserId = p.internalUserId || undefined;
227
+ companyId = p.companyId || undefined;
228
+ }
229
+ catch { }
230
+ }
231
+ if (!workspaceId)
232
+ return null;
233
+ const theme = await fetchWorkspaceTheme(assembly, workspaceId);
234
+ // Branding fetch failed — return null without caching so the next request
235
+ // re-attempts, matching the pre-AI-260 proxy (no placeholder branding served).
236
+ if (!theme)
237
+ return null;
238
+ const data = {
239
+ workspaceId,
240
+ clientId,
241
+ internalUserId,
242
+ companyId,
243
+ ...theme,
244
+ expiresAt: now + WORKSPACE_TTL_MS,
245
+ };
246
+ if (legacyTokenCache.size >= MAX_LEGACY_TOKEN_CACHE_SIZE) {
247
+ const oldest = legacyTokenCache.keys().next().value;
248
+ if (oldest !== undefined)
249
+ legacyTokenCache.delete(oldest);
250
+ }
251
+ legacyTokenCache.set(token, data);
252
+ return data;
253
+ }
254
+ async function decodeSessionTokenPayload(token) {
255
+ let assembly;
256
+ try {
257
+ assembly = await decryptSessionToken(token);
258
+ }
259
+ catch {
260
+ return null;
261
+ }
262
+ if (typeof assembly.getTokenPayload !== 'function')
263
+ return null;
264
+ try {
265
+ const payload = await assembly.getTokenPayload();
266
+ if (!payload?.workspaceId)
267
+ return null;
268
+ return {
269
+ workspaceId: payload.workspaceId,
270
+ sessionId: payload.tokenId || undefined,
271
+ clientId: payload.clientId || undefined,
272
+ internalUserId: payload.internalUserId || undefined,
273
+ companyId: payload.companyId || undefined,
274
+ };
275
+ }
276
+ catch {
277
+ return null;
278
+ }
279
+ }
280
+ // Whether this payload's identity may be compared against the cookie's at all.
281
+ // A payload carrying no identity claim at all had it stripped deliberately, so
282
+ // its missing clientId says nothing about the viewer — on a client surface that
283
+ // describes every single request, not a switch. Identity is only meaningful on a
284
+ // payload that carries some, or one with no session id and so predating session
285
+ // tokens entirely.
286
+ function payloadIdentityIsAuthoritative(decoded) {
287
+ return Boolean(decoded.clientId ||
288
+ decoded.internalUserId ||
289
+ decoded.companyId ||
290
+ !decoded.sessionId);
291
+ }
292
+ function previewIdentitiesMatch(cookieIdentity, decoded) {
293
+ return (cookieIdentity.workspaceId === decoded.workspaceId &&
294
+ (cookieIdentity.clientId ?? '') === (decoded.clientId ?? '') &&
295
+ (cookieIdentity.companyId ?? '') === (decoded.companyId ?? ''));
296
+ }
297
+ async function exchangeUrlToken(token, cookieSessionId) {
298
+ // Keyed on both inputs: the same token exchanged with a different cookie
299
+ // context is a different question, and must not reuse the other's verdict.
300
+ const key = `${cookieSessionId ?? ''}|${token}`;
301
+ const inFlight = exchangeInFlight.get(key);
302
+ if (inFlight)
303
+ return inFlight;
304
+ const promise = resolveAndExchange(token, cookieSessionId);
305
+ exchangeInFlight.set(key, promise);
306
+ try {
307
+ return await promise;
308
+ }
309
+ finally {
310
+ exchangeInFlight.delete(key);
311
+ }
312
+ }
313
+ // Returns the app's proxy function. The app's proxy.ts must still declare its
314
+ // own `export const config` literal: Next reads the matcher statically from that
315
+ // file and ignores an imported value.
316
+ export function createAssemblyProxy(options = {}) {
317
+ return (request) => handleRequest(request, options);
318
+ }
319
+ async function handleRequest(request, options) {
320
+ // Before CSRF / exchange: sendBeacon is a headerless POST and must not 403.
321
+ // POST only — a GET/HEAD to the path (crawler, prefetch, curious user) must
322
+ // not mint a fake painted line; it falls through to the normal proxy path.
323
+ if (request.method === 'POST' && isPaintedBeaconPath(request.nextUrl.pathname)) {
324
+ return handlePaintedBeacon();
325
+ }
326
+ const frameAncestors = [...STATIC_FRAME_ANCESTORS];
327
+ const requestHeaders = new Headers(request.headers);
328
+ // Never trust inbound values for the headers this proxy owns — the bearer it
329
+ // forwards, and the identity/branding headers the layout reads. Each is set
330
+ // below strictly from this proxy's own exchange/cookie resolution; stripping
331
+ // them first means a request that resolves to no identity can't inject a
332
+ // spoofed viewer (e.g. forcing client accent/font on an internal render).
333
+ stripProxyOwnedHeaders(requestHeaders);
334
+ let identity = null;
335
+ let theme = null;
336
+ let newBearer = null;
337
+ let newBearerExpiresAt = 0;
338
+ let failClosedReason = null;
339
+ // Master switch: while off, skip the exchange entirely and behave exactly as
340
+ // the pre-AI-260 legacy path. This keeps every foundation snapshot dormant
341
+ // until the exchange is deliberately enabled + the snapshot rebuilt.
342
+ const exchangeEnabled = isSessionExchangeEnabled();
343
+ // CSRF mitigation: when exchange is enabled the bearer travels as an
344
+ // HttpOnly SameSite=None cookie (required for cross-site iframe embedding),
345
+ // which the browser attaches to every request — including forged cross-site
346
+ // ones. A custom header cannot be set by a cross-site request without a
347
+ // CORS preflight, and this app never grants one, so requiring it here is
348
+ // sufficient to block CSRF. Server actions are managed by Next.js with its
349
+ // own Origin-based protection and cannot carry a custom header.
350
+ //
351
+ // The check only protects against ambient credentials, so a request carrying
352
+ // no cookie at all has nothing to protect: an inbound webhook or API client
353
+ // sends none. Such a request is passed to the route with identity stripped
354
+ // (the strip above already ran), skipping the token exchange and writing no
355
+ // cookies, so a forged `?token=` cannot mint a session through it. Any
356
+ // cookie, not only the bearer, keeps the request under the check.
357
+ if (exchangeEnabled && STATE_CHANGING_METHODS.has(request.method.toUpperCase())) {
358
+ const isServerAction = request.headers.has('next-action');
359
+ if (!isServerAction && !request.headers.has(APP_CSRF_HEADER)) {
360
+ if (request.headers.get('cookie')) {
361
+ return new NextResponse(null, { status: 403 });
362
+ }
363
+ return NextResponse.next({ request: { headers: requestHeaders } });
364
+ }
365
+ }
366
+ // Primary path: read bearer from the HttpOnly cookie set on first load.
367
+ const bearerCookie = exchangeEnabled
368
+ ? request.cookies.get(BEARER_COOKIE_NAME)?.value
369
+ : undefined;
370
+ let clearBearerCookie = false;
371
+ if (bearerCookie) {
372
+ // Derive identity from the self-describing bearer on EVERY request — no
373
+ // cache (a per-instance Map is worthless across serverless instances, and
374
+ // caching credentials is needless). A failed decode (expired/malformed
375
+ // bearer) clears the cookie so the next full-page load re-exchanges via
376
+ // ?token= instead of looping on 401.
377
+ identity = decodeBearer(bearerCookie);
378
+ if (!identity)
379
+ clearBearerCookie = true;
380
+ }
381
+ // Fallback for browsers that refuse to store the cookie (Safari blocks every
382
+ // cross-site-iframe cookie and has no CHIPS): the client holds the bearer in
383
+ // memory and sends it as a header. Only consulted when the cookie yielded no
384
+ // identity, so a browser with a working cookie takes exactly the same path as
385
+ // before. An undecodable value simply leaves identity null — there is no
386
+ // cookie here to clear, and the resulting 401 is what drives a re-exchange.
387
+ const clientBearer = exchangeEnabled
388
+ ? request.headers.get(CLIENT_BEARER_HEADER)
389
+ : null;
390
+ let servedFromClientBearer = false;
391
+ if (!identity && clientBearer) {
392
+ identity = decodeBearer(clientBearer);
393
+ servedFromClientBearer = Boolean(identity);
394
+ }
395
+ const urlToken = request.nextUrl.searchParams.get('token');
396
+ // One exchange attempt per request: the fallback below must not re-hit
397
+ // /v1/session for a token the block above already tried and failed on.
398
+ let urlTokenExchanged = false;
399
+ // A valid cookie does not always answer for an incoming ?token=. Two cases
400
+ // force a re-exchange:
401
+ // 1. New open: the token denotes a session we have not exchanged. Its
402
+ // context (notificationId/instanceId, and the viewer itself) lives on the
403
+ // server-side session record, so exchanging is the only way to pick it up
404
+ // — without this a Notification Center embed opened against a warm cookie
405
+ // never sees its notificationId. Anything that merely replays the URL (a
406
+ // reload, a server-action POST) re-sends the same session and keeps the
407
+ // cookie.
408
+ // 2. Viewer switch: the editor swaps Team/Client preview by updating iframe
409
+ // ?token=. Only consulted where identity is meaningful; case 1 catches the
410
+ // switch anyway, since a new preview always mints a new session.
411
+ if (exchangeEnabled && urlToken && identity && !clearBearerCookie) {
412
+ const decoded = await decodeSessionTokenPayload(urlToken);
413
+ const isNewOpen = Boolean(decoded?.sessionId && decoded.sessionId !== identity.sessionId);
414
+ const isViewerSwitch = Boolean(decoded &&
415
+ payloadIdentityIsAuthoritative(decoded) &&
416
+ !previewIdentitiesMatch(identity, decoded));
417
+ if (isNewOpen || isViewerSwitch) {
418
+ urlTokenExchanged = true;
419
+ // Send the cookie's session id with the exchange: on a slim token a
420
+ // viewer switch is not provable pre-exchange, so a rejected replay's
421
+ // consumer is compared server-side against the cookie's viewer instead.
422
+ const outcome = await exchangeUrlToken(urlToken, identity.sessionId);
423
+ if (outcome.kind === 'exchanged') {
424
+ identity = outcome.session;
425
+ theme = outcome.theme;
426
+ newBearer = outcome.bearer;
427
+ newBearerExpiresAt = outcome.bearerExpiresAt;
428
+ }
429
+ else {
430
+ failClosedReason = outcome.reason;
431
+ // A different-viewer verdict on the rejection is a proven mismatch:
432
+ // the spent token belonged to another viewer, so the cookie's bearer
433
+ // must not answer for it — same handling as a pre-exchange-provable
434
+ // viewer switch.
435
+ const provenViewerSwitch = isViewerSwitch || outcome.rejectedViewer === 'different_viewer';
436
+ if (provenViewerSwitch || outcome.reason === 'unavailable') {
437
+ if (provenViewerSwitch)
438
+ failClosedReason = 'viewer_switch';
439
+ // All ways to get here mean the cookie must not answer:
440
+ // - a different viewer is proven and no bearer could be minted;
441
+ // - the exchange could not be completed, so this token may still be
442
+ // unspent — a genuinely new session we merely failed to establish,
443
+ // not a replay. Rendering as the cookie's viewer would serve the
444
+ // previous session (and its stale notification/instance context).
445
+ identity = null;
446
+ theme = null;
447
+ // Only a proven mismatch justifies telling the browser to drop its
448
+ // bearer; a transient exchange failure must not log a live session out.
449
+ clearBearerCookie = provenViewerSwitch;
450
+ }
451
+ }
452
+ // isNewOpen-only + 'rejected' with no different-viewer verdict: the
453
+ // token is definitively spent by this same viewer (or by an exchange too
454
+ // old to say), i.e. a stale remount replaying one an earlier request
455
+ // already exchanged. Keep the cookie bearer — platform-api re-validates
456
+ // its signature on every real call, so dropping it buys no safety and
457
+ // locks the app out until the parent pushes a fresh token.
458
+ }
459
+ }
460
+ // Nothing resolved from a cookie and the block above did not already try this
461
+ // token (first load, or an undecodable/expired bearer) — resolve from ?token=.
462
+ if (!identity && urlToken && !urlTokenExchanged) {
463
+ if (!exchangeEnabled) {
464
+ // Exchange disabled: legacy per-request decrypt path (identity + theme).
465
+ const legacy = await resolveLegacyToken(urlToken);
466
+ if (legacy) {
467
+ identity = legacy;
468
+ theme = legacy;
469
+ }
470
+ }
471
+ else {
472
+ const outcome = await exchangeUrlToken(urlToken);
473
+ if (outcome.kind === 'exchanged') {
474
+ identity = outcome.session;
475
+ theme = outcome.theme;
476
+ newBearer = outcome.bearer;
477
+ newBearerExpiresAt = outcome.bearerExpiresAt;
478
+ }
479
+ else {
480
+ failClosedReason = outcome.reason;
481
+ }
482
+ // outcome.kind === 'reject': no bearer for this token (undecodable,
483
+ // tokenId-less, or a consumed/replayed single-use token). Leave identity
484
+ // null so no branding/identity headers are set and no bearer is
485
+ // forwarded. The app's getAssembly() then finds no bearer and
486
+ // re-authenticates instead of rendering.
487
+ }
488
+ }
489
+ // Cookie path: identity came from decoding the bearer, but branding did not.
490
+ // Fetch the workspace theme (cache-backed) for the CSP frame-ancestors +
491
+ // branding headers, authenticating with the bearer cookie itself.
492
+ // The already-valid bearer this request arrived with, from whichever source
493
+ // answered above.
494
+ const warmBearer = servedFromClientBearer
495
+ ? clientBearer
496
+ : bearerCookie && !clearBearerCookie
497
+ ? bearerCookie
498
+ : null;
499
+ if (exchangeEnabled && identity && !theme && warmBearer) {
500
+ theme = await fetchWorkspaceThemeForIdentity(identity.workspaceId, warmBearer);
501
+ }
502
+ if (options.onForward) {
503
+ // An app hook must not fail the request: by now a single-use session token
504
+ // may have been spent on the bearer this response carries, and a 500 would
505
+ // drop its Set-Cookie and leave the viewer with nothing to retry with.
506
+ try {
507
+ await options.onForward({ request, headers: requestHeaders, session: identity });
508
+ }
509
+ catch (err) {
510
+ console.error('assembly proxy onForward hook threw', err);
511
+ }
512
+ // The hook may copy inbound headers wholesale; the proxy-owned ones below
513
+ // must still come only from this proxy, including when no viewer resolved.
514
+ stripProxyOwnedHeaders(requestHeaders);
515
+ }
516
+ // Only an accepted identity (a decodable bearer cookie or a successful mint)
517
+ // drives branding. A token that decrypts but fails the exchange leaves
518
+ // identity null and sets no branding/isClient headers — the render falls back
519
+ // to neutral, matching the fail-closed data path, rather than letting an
520
+ // unaccepted token pick the accent/font.
521
+ let portalOrigin = null;
522
+ if (identity) {
523
+ const branding = theme ?? NO_BRANDING;
524
+ if (branding.portalUrl) {
525
+ portalOrigin = 'https://' + branding.portalUrl;
526
+ frameAncestors.push(portalOrigin);
527
+ }
528
+ // Branding (workspace theme) isn't carried by the bearer, and the proxy
529
+ // already fetched it for the CSP above — forward it so the app doesn't
530
+ // re-fetch the theme on every render. (The layout gates custom font/accent
531
+ // to client views.)
532
+ requestHeaders.set('x-assembly-color-accent', branding.colorAccent ?? '');
533
+ requestHeaders.set('x-assembly-font', branding.font ?? '');
534
+ requestHeaders.set('x-assembly-label-client-one', branding.labelClientOne);
535
+ requestHeaders.set('x-assembly-label-client-many', branding.labelClientMany);
536
+ requestHeaders.set('x-assembly-label-company-one', branding.labelCompanyOne);
537
+ requestHeaders.set('x-assembly-label-company-many', branding.labelCompanyMany);
538
+ // isClient gates accent/font in layout SSR. Always forward it so layout can
539
+ // fall back when getSessionIdentity() cannot read the bearer on first paint.
540
+ requestHeaders.set('x-assembly-is-client', identity.clientId ? 'true' : 'false');
541
+ // Distinct/workspace headers are legacy-only — bearer path reads them from
542
+ // the self-describing bearer via getSessionIdentity().
543
+ if (!exchangeEnabled) {
544
+ requestHeaders.set('x-assembly-distinct-id', identity.clientId ?? '');
545
+ requestHeaders.set('x-assembly-workspace-id', identity.workspaceId ?? '');
546
+ }
547
+ }
548
+ // Hand the active bearer to the render as a request header. On first load the
549
+ // bearer exists nowhere else the render can see it (the Set-Cookie below isn't
550
+ // visible to this same request); on later requests this simply mirrors the
551
+ // still-valid cookie. This is what lets a capable app render first-load from
552
+ // the bearer instead of decrypting the (now single-use-consumed) raw token.
553
+ const activeBearer = newBearer ?? (identity ? warmBearer : null);
554
+ if (activeBearer) {
555
+ requestHeaders.set(BEARER_HEADER_NAME, activeBearer);
556
+ }
557
+ // No accepted identity and no fresh bearer (an undecodable cookie, a viewer
558
+ // switch whose re-exchange minted nothing, an exchange that could not be
559
+ // completed): drop the bearer cookie from the FORWARDED request, so the
560
+ // render's getSessionIdentity() cannot resolve a viewer this request did not
561
+ // accept and bleed the previous view's data or branding into it. Keyed on
562
+ // identity rather than on clearBearerCookie — whether we tell the BROWSER to
563
+ // drop its cookie is a separate decision, and a transient exchange failure
564
+ // deliberately keeps the cookie while still refusing to render from it.
565
+ // A successful re-exchange sets BEARER_HEADER_NAME above (which the render
566
+ // prefers), so it is never stripped.
567
+ if (!identity && !newBearer) {
568
+ const cookieHeader = requestHeaders.get('cookie');
569
+ if (cookieHeader) {
570
+ const filtered = cookieHeader
571
+ .split(/; */)
572
+ .filter((c) => c && !c.startsWith(`${BEARER_COOKIE_NAME}=`))
573
+ .join('; ');
574
+ if (filtered)
575
+ requestHeaders.set('cookie', filtered);
576
+ else
577
+ requestHeaders.delete('cookie');
578
+ }
579
+ }
580
+ const response = NextResponse.next({ request: { headers: requestHeaders } });
581
+ response.headers.set('Content-Security-Policy', 'frame-ancestors ' + frameAncestors.join(' '));
582
+ // Write the bearer cookie via raw appended Set-Cookie headers (not
583
+ // response.cookies.set) so the partitioned record and the legacy-unpartitioned
584
+ // clear are emitted as TWO separate headers — ResponseCookies keys by name and
585
+ // would collapse them into one. The bearer is Partitioned (CHIPS) because the
586
+ // app renders in a cross-site iframe, where a plain SameSite=None cookie is a
587
+ // third-party cookie Chrome blocks under the 3P-cookie phase-out. CHIPS does
588
+ // NOT rescue Safari, which implements no such thing and blocks cross-site
589
+ // iframe cookies outright — that browser never stores the bearer at all and is
590
+ // carried by the client-held bearer above instead. We always also clear the
591
+ // unpartitioned record so a pre-rollout legacy cookie can't shadow this one.
592
+ // The probe is a client-readable twin written and cleared in lockstep: it is
593
+ // how the client learns the bearer was dropped.
594
+ if (newBearer) {
595
+ const maxAge = Math.max(0, Math.floor((newBearerExpiresAt - Date.now()) / 1_000));
596
+ response.headers.append('Set-Cookie', serializeBearerCookie('', 0, false));
597
+ response.headers.append('Set-Cookie', serializeBearerCookie(newBearer, maxAge, true));
598
+ response.headers.append('Set-Cookie', serializeCookieProbe(true));
599
+ }
600
+ else if (clearBearerCookie) {
601
+ response.headers.append('Set-Cookie', serializeBearerCookie('', 0, false));
602
+ response.headers.append('Set-Cookie', serializeBearerCookie('', 0, true));
603
+ response.headers.append('Set-Cookie', serializeCookieProbe(true, true));
604
+ }
605
+ else if (bearerCookie && isDocumentRequest(request)) {
606
+ // Refresh the probe on a document served from a live cookie: it outlives the
607
+ // bearer, so without this it would eventually lapse on a browser whose
608
+ // cookies work and send that browser down the cookie-less path.
609
+ response.headers.append('Set-Cookie', serializeCookieProbe(true));
610
+ }
611
+ // Mirror the workspace's portal origin (the same platform-resolved value the
612
+ // CSP above uses) into a client-READABLE cookie: the foundation's
613
+ // origin-bootstrap module reads it at bundle load and registers it with the
614
+ // bridge allowlist. The bridge has no trust bypass, so this is the only path
615
+ // by which a custom portal domain becomes a valid postMessage parent.
616
+ // Partitioned + SameSite=None for the same CHIPS reasons as the bearer;
617
+ // deliberately NOT HttpOnly — being JS-readable is the point, and the value
618
+ // is not a secret (it is served in the CSP header of every response).
619
+ if (portalOrigin && isDocumentRequest(request)) {
620
+ response.headers.append('Set-Cookie', `assembly-portal-origin=${encodeURIComponent(portalOrigin)}; Path=/; Max-Age=86400; SameSite=None; Secure; Partitioned`);
621
+ }
622
+ // Client-readable flags cookie for foundation modules gated on a specific
623
+ // flag (origin bootstrap, the paint beacon) — the bearer cookie is HttpOnly,
624
+ // so they can't derive this themselves. Two sources, one writer:
625
+ // - a fresh mint: the exchange response carries the evaluated allowlist, so
626
+ // seeding costs no extra call;
627
+ // - an expired/absent cookie on a live session: re-read via
628
+ // GET /v1/session/flags on the existing bearer.
629
+ // The short Max-Age is the freshness knob: a flag flip (including OFF — this
630
+ // is also the rollback lever) reaches every app within one cookie TTL at its
631
+ // next document load, without re-minting bearers. A null re-read means
632
+ // "undetermined" (network blip, expired bearer) — keep whatever the browser
633
+ // holds rather than treating the miss as all-flags-off. Same CHIPS reasoning
634
+ // as the portal-origin cookie above.
635
+ if (isDocumentRequest(request)) {
636
+ let flags = null;
637
+ if (newBearer) {
638
+ flags = identity?.flags ?? [];
639
+ }
640
+ else if (identity &&
641
+ bearerCookie &&
642
+ !clearBearerCookie &&
643
+ !request.cookies.has(CLIENT_FLAGS_COOKIE_NAME)) {
644
+ flags = await fetchSessionFlags(bearerCookie);
645
+ }
646
+ if (flags !== null) {
647
+ response.headers.append('Set-Cookie', `${CLIENT_FLAGS_COOKIE_NAME}=${encodeURIComponent(flags.join(','))}; Path=/; Max-Age=${FLAGS_COOKIE_TTL_SECONDS}; SameSite=None; Secure; Partitioned`);
648
+ }
649
+ }
650
+ if (exchangeEnabled && isDocumentRequest(request)) {
651
+ if (newBearer) {
652
+ logCookieOutcome('minted');
653
+ }
654
+ else if (identity && servedFromClientBearer) {
655
+ logCookieOutcome('served_from_client_bearer', failClosedReason ? { rejectedToken: failClosedReason } : undefined);
656
+ }
657
+ else if (identity && bearerCookie && !clearBearerCookie) {
658
+ // A rejected ?token= that the warm cookie answered for is still worth
659
+ // surfacing (replay signal) — carry the reason instead of dropping it.
660
+ logCookieOutcome('served_from_cookie', failClosedReason ? { rejectedToken: failClosedReason } : undefined);
661
+ }
662
+ else {
663
+ const reason = failClosedReason ??
664
+ (clearBearerCookie ? 'undecodable' : urlToken ? 'rejected' : 'no_session');
665
+ logCookieOutcome(`fail_closed:${reason}`);
666
+ }
667
+ }
668
+ return response;
669
+ }