@assembly-js/app-foundation 0.0.41 → 0.0.42

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.
@@ -57,22 +57,21 @@ if (existsSync(join(ROOT, PROXY_FILE))) {
57
57
  // like 'https://*.assembly.com' in the CSP list, and dependency-free means
58
58
  // no real tokenizer.
59
59
  const proxy = readFileSync(join(ROOT, PROXY_FILE), 'utf8').replace(/^\s*\/\/.*$/gm, '');
60
+ // The package's proxy routes the beacon itself.
61
+ const usesPackageProxy = /createAssemblyProxy\s*\(/.test(proxy);
60
62
  const importsHelpers =
61
63
  /isPaintedBeaconPath/.test(proxy) && /handlePaintedBeacon/.test(proxy);
62
64
  const usesHelpers =
63
65
  /isPaintedBeaconPath\s*\(/.test(proxy) && /handlePaintedBeacon\s*\(/.test(proxy);
64
66
 
65
- if (!importsHelpers || !usesHelpers) {
67
+ if (!usesPackageProxy && (!importsHelpers || !usesHelpers)) {
66
68
  console.error(
67
- `check-paint-beacon: ${PROXY_FILE} must route the beacon's POST before CSRF/exchange:\n\n` +
68
- " import { isPaintedBeaconPath, handlePaintedBeacon } from '@assembly-js/app-foundation/client';\n" +
69
- ' // …first thing in the proxy function:\n' +
70
- " if (request.method === 'POST' && isPaintedBeaconPath(request.nextUrl.pathname)) {\n" +
71
- ' return handlePaintedBeacon();\n' +
72
- ' }\n\n' +
69
+ `check-paint-beacon: ${PROXY_FILE} must route the beacon's POST before CSRF/exchange. ` +
70
+ 'The platform sign-in proxy does this; restore it:\n\n' +
71
+ " import { createAssemblyProxy } from '@assembly-js/app-foundation/server';\n" +
72
+ ' export const proxy = createAssemblyProxy();\n\n' +
73
73
  "sendBeacon is a headerless POST — routing it through the normal CSRF/exchange path 403s it, " +
74
- 'silently breaking the paint signal. POST only: a GET to the path must fall through, not ' +
75
- 'mint a fake painted line.',
74
+ 'silently breaking the paint signal.',
76
75
  );
77
76
  process.exit(1);
78
77
  }
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env node
2
+ // Fails the build when the app's proxy does not behave like the sign-in proxy:
3
+ // it must reject a state-changing request that lacks the CSRF header, forward
4
+ // one that carries it without the proxy ever adding the header itself, leave
5
+ // the re-exchange route reachable, and accept the cookieless (Safari) bearer.
6
+ // A text check cannot tell a gate from a bypass — an update turn once satisfied
7
+ // "proxy.ts contains APP_CSRF_HEADER" by stamping the header on every request —
8
+ // so this loads the app's real proxy and sends it requests.
9
+ //
10
+ // Runs under node like the other checks, then re-executes itself under bun,
11
+ // which can import the app's TypeScript proxy directly. In that child
12
+ // `server-only` and the node SDK are stubbed and fetch is disabled: no probe
13
+ // carries a session token, so nothing here needs the network.
14
+ //
15
+ // Like check-crons, a broken proxy fails the build only when the current turn
16
+ // changed it (or with --strict, which the update acceptance passes); otherwise
17
+ // the findings are warnings, since there is no agent to act on them. An app
18
+ // still on the pre-bearer foundation only ever warns outside --strict: the
19
+ // platform security update is what fixes it, not an ordinary turn.
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+ import { spawnSync } from 'node:child_process';
22
+ import { createRequire } from 'node:module';
23
+ import { homedir } from 'node:os';
24
+ import { join } from 'node:path';
25
+ import { fileURLToPath, pathToFileURL } from 'node:url';
26
+
27
+ const ROOT = process.cwd();
28
+ const STRICT = process.argv.includes('--strict');
29
+ const PROXY_FILES = ['proxy.ts', 'middleware.ts'];
30
+ const REEXCHANGE_ROUTE = 'app/api/assembly-reexchange/route.ts';
31
+ const REEXCHANGE_PATH = '/api/assembly-reexchange';
32
+ const PRE_BEARER_MARKER = 'lib/assembly.ts';
33
+ const VENDORED_BEARER_MARKER = 'lib/mint-bearer.ts';
34
+ const RESULT_PREFIX = 'check-proxy-csrf-result ';
35
+ const CHILD_TIMEOUT_MS = 60_000;
36
+
37
+ const TURN_BASELINE_ENV = 'ASSEMBLY_TURN_BASELINE_SHA';
38
+ const PROXY_WIRING_PATHS = [...PROXY_FILES, 'app/api/assembly-reexchange/'];
39
+
40
+ // Each finding id is a fixed string with a fixed explanation; the request that
41
+ // produced it travels as JSON detail, so reports group by id.
42
+ const FINDINGS = {
43
+ proxy_missing: 'no proxy.ts at the app root',
44
+ proxy_unloadable: 'the proxy module could not be loaded',
45
+ proxy_threw: 'the proxy threw while handling a request',
46
+ proxy_skips_path: 'the proxy matcher leaves a path that serves app requests unprotected',
47
+ csrf_gate_missing: 'a state-changing request without the CSRF header was forwarded',
48
+ csrf_header_stamped: 'the proxy added the CSRF header to a request that did not carry it',
49
+ csrf_request_blocked: 'a state-changing request carrying the CSRF header was not forwarded',
50
+ csrf_header_stripped: 'the proxy removed the CSRF header from a forwarded request',
51
+ server_action_blocked: 'a Next server action was not forwarded',
52
+ reexchange_not_excluded: 'the proxy matcher does not exclude the re-exchange route',
53
+ reexchange_blocked_by_proxy: 'the proxy does not pass a re-exchange request through with its CSRF header',
54
+ reexchange_route_missing: `${REEXCHANGE_ROUTE} is missing`,
55
+ reexchange_csrf_missing: 'the re-exchange route accepts a request without the CSRF header',
56
+ reexchange_always_forbidden: 'the re-exchange route rejects a request that carries the CSRF header',
57
+ cookieless_fallback_missing: 'a bearer sent in the client-bearer header was not forwarded to the render',
58
+ };
59
+
60
+ if (process.env.ASSEMBLY_PROXY_CHECK_CHILD === '1') {
61
+ await runProbes();
62
+ } else {
63
+ runCheck();
64
+ }
65
+
66
+ // --- parent (node) ---------------------------------------------------------
67
+
68
+ function runCheck() {
69
+ const proxyFile = PROXY_FILES.find((f) => existsSync(join(ROOT, f)));
70
+ // An app still on the pre-bearer foundation (lib/assembly.ts without
71
+ // lib/mint-bearer.ts, the same markers check-token-usage reads, and no
72
+ // package proxy) cannot pass this check by an ordinary turn's edits: the platform
73
+ // security update is what moves it. Its findings stay warnings that say so,
74
+ // unless --strict (the update's own acceptance) asks for a verdict.
75
+ const usesPackageProxy =
76
+ Boolean(proxyFile) && /createAssemblyProxy\s*\(/.test(readFileSync(join(ROOT, proxyFile), 'utf8'));
77
+ const preBearer =
78
+ existsSync(join(ROOT, PRE_BEARER_MARKER)) &&
79
+ !existsSync(join(ROOT, VENDORED_BEARER_MARKER)) &&
80
+ !usesPackageProxy;
81
+ const fatal = STRICT || (!preBearer && proxyChangedThisTurn());
82
+ const report = (findings) => reportFindings(findings, fatal, preBearer);
83
+ if (!proxyFile) {
84
+ report([{ id: 'proxy_missing', detail: {} }]);
85
+ return;
86
+ }
87
+
88
+ const bun = findBun();
89
+ const child = spawnSync(bun, [fileURLToPath(import.meta.url), ...process.argv.slice(2)], {
90
+ cwd: ROOT,
91
+ encoding: 'utf8',
92
+ timeout: CHILD_TIMEOUT_MS,
93
+ env: {
94
+ ...process.env,
95
+ ASSEMBLY_PROXY_CHECK_CHILD: '1',
96
+ ASSEMBLY_PROXY_CHECK_FILE: proxyFile,
97
+ // The gate is dormant while the exchange is off, so probe it switched on.
98
+ ENABLE_APP_SESSION_TOKEN_EXCHANGE: 'true',
99
+ ASSEMBLY_API_KEY: '',
100
+ },
101
+ });
102
+ if (child.error) {
103
+ // No bun to load TypeScript with (e.g. a deploy builder): nothing to prove
104
+ // here, and the turn that wrote the proxy already ran this in the sandbox.
105
+ const line = `check-proxy-csrf: skipped, bun is not available (${child.error.code ?? 'spawn error'})`;
106
+ if (fatal) {
107
+ console.error(line);
108
+ process.exit(1);
109
+ }
110
+ console.warn(line);
111
+ return;
112
+ }
113
+ const resultLine = (child.stdout ?? '')
114
+ .split('\n')
115
+ .reverse()
116
+ .find((l) => l.startsWith(RESULT_PREFIX));
117
+ if (!resultLine) {
118
+ const stderrTail = (child.stderr ?? '').trim().split('\n').slice(-8).join('\n');
119
+ report([{ id: 'proxy_unloadable', detail: { file: proxyFile, stderr: stderrTail } }]);
120
+ return;
121
+ }
122
+ const { findings } = JSON.parse(resultLine.slice(RESULT_PREFIX.length));
123
+ if (findings.length > 0) report(findings);
124
+ }
125
+
126
+ function reportFindings(findings, fatal, preBearer) {
127
+ const out = fatal ? console.error : console.warn;
128
+ out('');
129
+ out(
130
+ fatal
131
+ ? 'check-proxy-csrf: the app proxy does not enforce the sign-in CSRF gate:'
132
+ : 'check-proxy-csrf: the app proxy does not enforce the sign-in CSRF gate (pre-existing; this build is not failed for it):',
133
+ );
134
+ out('');
135
+ for (const { id, detail } of findings) {
136
+ out(` - ${id}: ${FINDINGS[id] ?? id} ${JSON.stringify(detail)}`);
137
+ }
138
+ out('');
139
+ if (preBearer && !fatal) {
140
+ out('This app predates the platform sign-in proxy. The platform security update moves it onto that');
141
+ out('proxy; do not rewrite proxy.ts in this turn to satisfy this check.');
142
+ return;
143
+ }
144
+ out('proxy.ts is the platform sign-in proxy. It must be exactly this, plus the app\'s own additions in onForward:');
145
+ out('');
146
+ out(" import { createAssemblyProxy } from '@assembly-js/app-foundation/server';");
147
+ out('');
148
+ out(' export const proxy = createAssemblyProxy({');
149
+ out(' // optional: onForward({ request, headers, session }) { headers.set(...) },');
150
+ out(' });');
151
+ out('');
152
+ out(' export const config = {');
153
+ out(' matcher: [');
154
+ out(" '/((?!_next/static(?:/|$)|_next/image(?:/|$)|api/assembly-reexchange(?:/|$)).*)',");
155
+ out(' ],');
156
+ out(' };');
157
+ out('');
158
+ out('Never set or delete the CSRF header in the proxy, and never write your own gate.');
159
+ if (fatal) process.exit(1);
160
+ }
161
+
162
+ function findBun() {
163
+ const execPath = process.env.npm_execpath ?? '';
164
+ if (/\bbun(\.exe)?$/.test(execPath)) return execPath;
165
+ const installed = join(process.env.BUN_INSTALL ?? join(homedir(), '.bun'), 'bin', 'bun');
166
+ return existsSync(installed) ? installed : 'bun';
167
+ }
168
+
169
+ function proxyChangedThisTurn() {
170
+ const baseline = (process.env[TURN_BASELINE_ENV] ?? '').trim();
171
+ if (!baseline) return false;
172
+ const listings = [
173
+ { args: ['diff', '--name-only', `${baseline}..HEAD`], parse: (line) => line },
174
+ // Porcelain rows are "XY path", or "XY old -> new" for a rename.
175
+ { args: ['status', '--porcelain', '--untracked-files=all'], parse: (line) => line.slice(3).split(' -> ').pop() },
176
+ ];
177
+ for (const { args, parse } of listings) {
178
+ const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
179
+ if (result.status !== 0) return true;
180
+ const changed = result.stdout.split('\n').map((line) => parse(line).trim()).filter(Boolean);
181
+ if (changed.some((file) => PROXY_WIRING_PATHS.some((p) => (p.endsWith('/') ? file.startsWith(p) : file === p)))) {
182
+ return true;
183
+ }
184
+ }
185
+ return false;
186
+ }
187
+
188
+ // --- child (bun) -----------------------------------------------------------
189
+
190
+ async function runProbes() {
191
+ const findings = [];
192
+ const add = (id, detail) => findings.push({ id, detail });
193
+ const finish = () => {
194
+ console.log(RESULT_PREFIX + JSON.stringify({ findings }));
195
+ process.exit(0);
196
+ };
197
+
198
+ stubServerModules();
199
+ // Next's server runtime installs this global before any app module loads;
200
+ // its request-scoped helpers (after, headers) throw at import without it.
201
+ globalThis.AsyncLocalStorage ??= (await import('node:async_hooks')).AsyncLocalStorage;
202
+ globalThis.fetch = () => Promise.reject(new Error('network is disabled in check-proxy-csrf'));
203
+
204
+ const appRequire = createRequire(join(ROOT, 'package.json'));
205
+ const { NextRequest } = await import(appRequire.resolve('next/server'));
206
+ const { APP_CSRF_HEADER, BEARER_HEADER_NAME, CLIENT_BEARER_HEADER } = await import(
207
+ appRequire.resolve('@assembly-js/app-foundation/client')
208
+ );
209
+ const proxyFile = process.env.ASSEMBLY_PROXY_CHECK_FILE;
210
+ const mod = await import(pathToFileURL(join(ROOT, proxyFile)).href);
211
+ const handler = mod.proxy ?? mod.middleware ?? mod.default;
212
+ if (typeof handler !== 'function') {
213
+ add('proxy_unloadable', { file: proxyFile, reason: 'no proxy export' });
214
+ finish();
215
+ }
216
+
217
+ const origin = 'http://localhost:3000';
218
+ const send = async (fn, probe, method, path, headers = {}) => {
219
+ const request = new NextRequest(new URL(path, origin), { method, headers });
220
+ try {
221
+ return describeOutcome(await fn(request), headers);
222
+ } catch (err) {
223
+ add('proxy_threw', { probe, method, path, error: String(err?.message ?? err) });
224
+ return null;
225
+ }
226
+ };
227
+ const apiPath = '/api/proxy-check';
228
+ // CSRF rides on ambient credentials, so the forged-write probes carry a
229
+ // cookie; a cookieless write (an inbound webhook) is allowed through.
230
+ const AMBIENT_COOKIE = 'assembly-proxy-check=1';
231
+ const CROSS_SITE_FORM = {
232
+ cookie: AMBIENT_COOKIE,
233
+ origin: 'https://attacker.example',
234
+ 'sec-fetch-site': 'cross-site',
235
+ 'content-type': 'application/x-www-form-urlencoded',
236
+ };
237
+ // Next only runs the proxy where config.matcher says, so a probe on any other
238
+ // path would pass against a gate no real request reaches.
239
+ const skipped = new Set();
240
+ for (const path of [apiPath, '/']) {
241
+ if (!(await proxyRunsOn(appRequire, mod.config, path))) {
242
+ skipped.add(path);
243
+ add('proxy_skips_path', { path });
244
+ }
245
+ }
246
+ const proxy = async (probe, method, path, headers) =>
247
+ skipped.has(path) ? null : send(handler, probe, method, path, headers);
248
+
249
+ for (const [method, path] of [
250
+ ['POST', apiPath],
251
+ ['PUT', apiPath],
252
+ ['PATCH', apiPath],
253
+ ['DELETE', apiPath],
254
+ ['POST', '/'],
255
+ ]) {
256
+ const out = await proxy('no_csrf_header', method, path, { cookie: AMBIENT_COOKIE });
257
+ if (out && !out.rejected) add('csrf_gate_missing', { method, path, status: out.status, variant: 'cookie' });
258
+ }
259
+ // What a cross-site form post actually sends: a gate keyed on any of these
260
+ // (e.g. trusting a request because it has an Origin) must still reject it.
261
+ for (const path of [apiPath, '/']) {
262
+ const out = await proxy('cross_site_form', 'POST', path, CROSS_SITE_FORM);
263
+ if (out && !out.rejected) add('csrf_gate_missing', { method: 'POST', path, status: out.status, variant: 'cross_site_form' });
264
+ }
265
+
266
+ const read = await proxy('get_without_header', 'GET', apiPath);
267
+ if (read?.forwarded && read.forwardedHeader(APP_CSRF_HEADER) !== null) {
268
+ add('csrf_header_stamped', { method: 'GET', path: apiPath });
269
+ }
270
+
271
+ // Legitimate writes come from a signed-in browser, so they carry its cookies too.
272
+ const write = await proxy('with_csrf_header', 'POST', apiPath, { cookie: AMBIENT_COOKIE, [APP_CSRF_HEADER]: '1' });
273
+ if (write && !write.forwarded) {
274
+ add('csrf_request_blocked', { method: 'POST', path: apiPath, status: write.status });
275
+ } else if (write && write.forwardedHeader(APP_CSRF_HEADER) === null) {
276
+ add('csrf_header_stripped', { method: 'POST', path: apiPath });
277
+ }
278
+
279
+ const action = await proxy('server_action', 'POST', '/', { cookie: AMBIENT_COOKIE, 'next-action': 'proxy-check' });
280
+ if (action && !action.forwarded) add('server_action_blocked', { status: action.status });
281
+
282
+ // The route enforces CSRF itself; the proxy must either not run on it or
283
+ // pass it through untouched, else silent renewal is dead after the bearer's
284
+ // first hour.
285
+ if (await proxyRunsOn(appRequire, mod.config, REEXCHANGE_PATH)) {
286
+ add('reexchange_not_excluded', { path: REEXCHANGE_PATH });
287
+ const out = await proxy('reexchange', 'POST', REEXCHANGE_PATH, { [APP_CSRF_HEADER]: '1' });
288
+ if (out && (!out.forwarded || out.forwardedHeader(APP_CSRF_HEADER) === null)) {
289
+ add('reexchange_blocked_by_proxy', { status: out.status });
290
+ }
291
+ }
292
+ if (!existsSync(join(ROOT, REEXCHANGE_ROUTE))) {
293
+ add('reexchange_route_missing', {});
294
+ } else {
295
+ const route = await import(pathToFileURL(join(ROOT, REEXCHANGE_ROUTE)).href);
296
+ if (typeof route.POST !== 'function') {
297
+ add('reexchange_route_missing', { reason: 'no POST export' });
298
+ } else {
299
+ const bare = await send(route.POST, 'reexchange_route', 'POST', REEXCHANGE_PATH);
300
+ if (bare && bare.status !== 403) add('reexchange_csrf_missing', { status: bare.status });
301
+ const headed = await send(route.POST, 'reexchange_route', 'POST', REEXCHANGE_PATH, {
302
+ [APP_CSRF_HEADER]: '1',
303
+ });
304
+ if (headed && headed.status === 403) add('reexchange_always_forbidden', { status: headed.status });
305
+ }
306
+ }
307
+
308
+ // Browsers that refuse the partitioned cookie (Safari) hold the bearer in
309
+ // memory and send it as a header; the proxy must hand it to the render.
310
+ const bearer = unsignedBearer({ workspace_id: 'proxy-check', exp: Math.floor(Date.now() / 1000) + 3600 });
311
+ const cookieless = await proxy('cookieless_bearer', 'GET', '/', { [CLIENT_BEARER_HEADER]: bearer });
312
+ if (cookieless && (!cookieless.forwarded || cookieless.forwardedHeader(BEARER_HEADER_NAME) !== bearer)) {
313
+ add('cookieless_fallback_missing', { status: cookieless.status });
314
+ }
315
+
316
+ finish();
317
+ }
318
+
319
+ // What the proxy did with a request: forwarded (NextResponse.next / rewrite, or
320
+ // no response at all) or answered itself, and for a forward, the headers the
321
+ // render would see. NextResponse.next({ request: { headers } }) lists every
322
+ // forwarded header in x-middleware-override-headers; without that header the
323
+ // inbound request passes through unchanged.
324
+ function describeOutcome(response, inbound) {
325
+ if (!response) {
326
+ return { forwarded: true, rejected: false, status: 200, forwardedHeader: (n) => inbound[n] ?? null };
327
+ }
328
+ const forwarded =
329
+ response.headers.get('x-middleware-next') === '1' || response.headers.has('x-middleware-rewrite');
330
+ const override = response.headers.get('x-middleware-override-headers');
331
+ const forwardedHeader = (name) => {
332
+ if (override === null) return inbound[name] ?? null;
333
+ return override.split(',').map((h) => h.trim()).includes(name)
334
+ ? response.headers.get(`x-middleware-request-${name}`)
335
+ : null;
336
+ };
337
+ return {
338
+ forwarded,
339
+ rejected: !forwarded && response.status >= 400 && response.status < 500,
340
+ status: response.status,
341
+ forwardedHeader,
342
+ };
343
+ }
344
+
345
+ async function proxyRunsOn(appRequire, config, path) {
346
+ if (!config?.matcher) return true;
347
+ try {
348
+ const { unstable_doesMiddlewareMatch } = await import(
349
+ appRequire.resolve('next/experimental/testing/server')
350
+ );
351
+ return unstable_doesMiddlewareMatch({ config, url: path });
352
+ } catch {
353
+ // No matcher helper in this Next version: assume the proxy runs there.
354
+ return true;
355
+ }
356
+ }
357
+
358
+ function unsignedBearer(claims) {
359
+ const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
360
+ return `${b64({ alg: 'none', typ: 'JWT' })}.${b64(claims)}.proxy-check`;
361
+ }
362
+
363
+ // `server-only` throws outside a React Server Component bundle, and the SDK
364
+ // would reach the network; neither has anything to do with the CSRF gate. Bun
365
+ // runtime plugins intercept by resolved file path, not by specifier.
366
+ function stubServerModules() {
367
+ const stub = (dir, contents) => ({
368
+ filter: new RegExp(`node_modules[\\\\/]${dir}[\\\\/].*\\.[cm]?js$`),
369
+ contents,
370
+ });
371
+ const stubs = [
372
+ stub('server-only', 'export {};'),
373
+ stub(
374
+ '@assembly-js[\\\\/]node-sdk',
375
+ "export const OpenAPI = { BASE: 'http://127.0.0.1:9' };\n" +
376
+ "export async function assemblyApi() { throw new Error('network is disabled in check-proxy-csrf'); }\n",
377
+ ),
378
+ ];
379
+ Bun.plugin({
380
+ name: 'check-proxy-csrf-stubs',
381
+ setup(build) {
382
+ for (const { filter, contents } of stubs) {
383
+ build.onLoad({ filter }, () => ({ contents, loader: 'js' }));
384
+ }
385
+ },
386
+ });
387
+ }
@@ -0,0 +1,11 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { type ExchangedSession } from './session-exchange.js';
3
+ export interface AssemblyProxyForwardContext {
4
+ request: NextRequest;
5
+ headers: Headers;
6
+ session: ExchangedSession | null;
7
+ }
8
+ export interface AssemblyProxyOptions {
9
+ onForward?: (context: AssemblyProxyForwardContext) => void | Promise<void>;
10
+ }
11
+ export declare function createAssemblyProxy(options?: AssemblyProxyOptions): (request: NextRequest) => Promise<NextResponse | Response>;
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
+ }
package/dist/server.d.ts CHANGED
@@ -12,6 +12,7 @@ export * from './labels.js';
12
12
  export * from './notifications.js';
13
13
  export * from './cron.js';
14
14
  export * from './replace-rows.js';
15
+ export * from './proxy.js';
15
16
  export * from './api-errors.js';
16
17
  export * from './params.js';
17
18
  export * from './client.js';
package/dist/server.js CHANGED
@@ -16,6 +16,7 @@ export * from './labels.js';
16
16
  export * from './notifications.js';
17
17
  export * from './cron.js';
18
18
  export * from './replace-rows.js';
19
+ export * from './proxy.js';
19
20
  // Returns NextResponse, so route handlers only — but nothing here reads a
20
21
  // secret, which is why a client file may still `import type` from them.
21
22
  export * from './api-errors.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.41",
3
+ "version": "0.0.42",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "scripts": {
32
32
  "build": "tsc && tsc-alias",
33
- "test": "bun test && tsc -p tsconfig.replace-rows.json",
33
+ "test": "npm run build && bun test --path-ignore-patterns='**/proxy.test.ts' && bun test ./src/proxy.test.ts && tsc -p tsconfig.replace-rows.json",
34
34
  "prepublishOnly": "npm run build && npm run verify:dist && npm run verify:bin",
35
35
  "verify:dist": "node scripts/verify-dist-resolves.mjs",
36
36
  "verify:bin": "node scripts/verify-bin.mjs"