@assembly-js/app-foundation 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -19,6 +19,13 @@ A patch release never changes what an app's build requires; a minor release may
19
19
  not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
20
20
  `bump: minor` change file.
21
21
 
22
+ ## 0.6.0
23
+ - [new] `AuthUnavailableError` carries a fixed digest (`AUTH_UNAVAILABLE_DIGEST`), so an app's error boundary can recognise a platform outage after production has redacted the error.
24
+ - [action] A platform outage reaches the retry screen from every page, not only the root layout: `app/error.tsx` renders it on that digest and no helper or route may swallow the error; the renewal limiter earlier updates copied into the app is removed now that the package bounds renewal itself.
25
+
26
+ ## 0.5.4
27
+ - [fix] A tab left open renews its sign-in before it expires, so clicking into a page after an hour no longer shows the signed-out screen.
28
+
22
29
  ## 0.5.3
23
30
  - [fix] Sign-in renewal is now bounded to one attempt per 30 seconds per tab, so a burst of expired requests renews once and a late response can no longer undo a fresh renewal.
24
31
 
@@ -4,131 +4,277 @@
4
4
  // before any deps beyond Node exist.
5
5
  //
6
6
  // Since foundation 0.0.40, getSessionIdentity() throws AuthUnavailableError
7
- // when the platform can't be reached. The app must catch it in the root layout
8
- // and render a retry screen, and its client context must bound session renewal
9
- // with createRenewalGate — otherwise an outage fails every page and every
10
- // failed action renews the session without limit.
7
+ // when the platform can't be reached, and the error carries a fixed digest
8
+ // (AUTH_UNAVAILABLE_DIGEST) that survives production's error redaction. The
9
+ // contract is therefore small: app/error.tsx renders the retry screen on that
10
+ // digest, so a page or nested layout that throws it is covered without code of
11
+ // its own; the root layout catches it itself, because its errors bypass the
12
+ // boundary; and nothing in between swallows it — a catch around the call (or a
13
+ // .catch() chained onto it) rethrows AuthUnavailableError, or a route handler
14
+ // returns handleRouteError(err)
15
+ // (a retryable 503). A helper with no catch propagates and passes; the pages
16
+ // that call such a helper are not followed, so a page that swallows a helper's
17
+ // error is only caught when the page itself calls getSessionIdentity().
11
18
  //
12
- // An app whose root layout never calls getSessionIdentity predates identity
13
- // confirmation, so the contract never applied and this check exits 0.
19
+ // An app with no getSessionIdentity() call predates identity confirmation, so
20
+ // the contract never applied and this check exits 0.
21
+ //
22
+ // Session renewal is bounded inside the package since 0.5.3, so the vendored
23
+ // app/components/renewal-gate.ts that earlier updates copied in is superseded:
24
+ // --strict (the update acceptance) asks for its removal, an ordinary turn only
25
+ // mentions it.
14
26
  //
15
27
  // A gap is a warning on an ordinary turn (no agent there can act on an app
16
- // that was already behind) and fails the build with --strict (the update
17
- // acceptance) or when ASSEMBLY_TURN_BASELINE_SHA says this turn changed one of
18
- // the contract files.
19
- import { existsSync, readFileSync } from 'node:fs';
28
+ // that was already behind) and fails the build with --strict or when
29
+ // ASSEMBLY_TURN_BASELINE_SHA says this turn changed one of the contract files.
30
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
20
31
  import { spawnSync } from 'node:child_process';
21
- import { join } from 'node:path';
32
+ import { join, relative } from 'node:path';
22
33
 
23
34
  const ROOT = process.cwd();
24
35
  const STRICT = process.argv.includes('--strict');
25
36
  const TURN_BASELINE_ENV = 'ASSEMBLY_TURN_BASELINE_SHA';
26
37
 
27
38
  const LAYOUT_FILE = 'app/layout.tsx';
39
+ const ERROR_BOUNDARY_FILE = 'app/error.tsx';
28
40
  const CONTEXT_FILE = 'app/components/assembly-context.tsx';
29
41
  const RETRY_SCREEN_FILE = 'app/components/auth-unavailable.tsx';
30
42
  const RENEWAL_GATE_FILE = 'app/components/renewal-gate.ts';
31
- const CONTRACT_FILES = [LAYOUT_FILE, CONTEXT_FILE, RETRY_SCREEN_FILE, RENEWAL_GATE_FILE];
43
+ const RENEWAL_GATE_TEST_FILE = 'app/components/renewal-gate.test.ts';
44
+ const SCAN_DIRS = ['app', 'lib'];
45
+ const SKIP_DIRS = new Set(['node_modules', '.next']);
46
+
47
+ const IDENTITY_CALL = /getSessionIdentity\s*\(/;
48
+ const HANDLED = /AuthUnavailableError|handleRouteError\s*\(/;
49
+ // A catch that rethrows at all lets the outage through to the boundary.
50
+ const CATCH_LETS_THROUGH = /\bthrow\b|AuthUnavailableError|handleRouteError\s*\(/;
51
+ const BOUNDARY_COMPARES_DIGEST = /\.digest\s*===?\s*AUTH_UNAVAILABLE_DIGEST|AUTH_UNAVAILABLE_DIGEST\s*===?\s*[\w.]*digest/;
52
+ const BOUNDARY_RENDERS_RETRY = /<AuthUnavailable\s*\/?>/;
32
53
 
33
54
  const read = (file) => (existsSync(join(ROOT, file)) ? readFileSync(join(ROOT, file), 'utf8') : null);
34
55
 
35
- const layout = read(LAYOUT_FILE);
36
- if (layout === null || !/\bgetSessionIdentity\b/.test(layout)) {
37
- process.exit(0); // pre-identity app: not this check's contract
56
+ // Server .ts/.tsx files under app/ and lib/: no 'use client' directive, no tests.
57
+ function serverFiles() {
58
+ const files = [];
59
+ const walk = (dir) => {
60
+ for (const entry of readdirSync(dir)) {
61
+ if (SKIP_DIRS.has(entry)) continue;
62
+ const full = join(dir, entry);
63
+ if (statSync(full).isDirectory()) {
64
+ walk(full);
65
+ continue;
66
+ }
67
+ if (!/\.tsx?$/.test(entry) || /\.(test|spec)\.tsx?$/.test(entry) || /\.d\.ts$/.test(entry)) continue;
68
+ files.push(relative(ROOT, full).split('\\').join('/'));
69
+ }
70
+ };
71
+ for (const dir of SCAN_DIRS) {
72
+ if (existsSync(join(ROOT, dir))) walk(join(ROOT, dir));
73
+ }
74
+ return files.filter((file) => !/^\s*['"]use client['"]/m.test(read(file)));
75
+ }
76
+
77
+ function callsIdentity(source) {
78
+ return source
79
+ .split('\n')
80
+ .some((line) => !/^\s*(\/\/|\/\*|\*)/.test(line) && IDENTITY_CALL.test(line));
81
+ }
82
+
83
+ // Index of the bracket that closes the one at `open` (a brace or a paren),
84
+ // skipping brackets inside strings, template literals and comments. -1 when
85
+ // unbalanced.
86
+ function closingBracket(source, open) {
87
+ const opener = source[open];
88
+ const closer = opener === '(' ? ')' : '}';
89
+ let depth = 0;
90
+ for (let i = open; i < source.length; i++) {
91
+ const ch = source[i];
92
+ const next = source[i + 1];
93
+ if (ch === '/' && next === '/') {
94
+ i = source.indexOf('\n', i);
95
+ if (i === -1) return -1;
96
+ continue;
97
+ }
98
+ if (ch === '/' && next === '*') {
99
+ i = source.indexOf('*/', i + 2);
100
+ if (i === -1) return -1;
101
+ i += 1;
102
+ continue;
103
+ }
104
+ if (ch === "'" || ch === '"' || ch === '`') {
105
+ for (i += 1; i < source.length && source[i] !== ch; i++) if (source[i] === '\\') i++;
106
+ continue;
107
+ }
108
+ if (ch === opener) depth++;
109
+ else if (ch === closer && --depth === 0) return i;
110
+ }
111
+ return -1;
38
112
  }
39
113
 
40
- const context = read(CONTEXT_FILE);
114
+ // The handlers that can swallow a getSessionIdentity() call: the catch block
115
+ // of a try that contains the call, and a .catch() chained onto the call. A
116
+ // catch elsewhere in the file (JSON parsing, a fallback) is not this contract's
117
+ // business, and a call outside either propagates on its own.
118
+ function catchBlocksAround(source) {
119
+ const blocks = [];
120
+ const calls = [...source.matchAll(/getSessionIdentity\s*\(/g)].map((m) => m.index);
121
+ for (const match of source.matchAll(/\btry\s*\{/g)) {
122
+ const open = match.index + match[0].length - 1;
123
+ const close = closingBracket(source, open);
124
+ if (close === -1 || !calls.some((at) => at > open && at < close)) continue;
125
+ const catchMatch = /^\s*catch\b[^{]*\{/.exec(source.slice(close + 1));
126
+ if (!catchMatch) continue;
127
+ const catchOpen = close + 1 + catchMatch[0].length - 1;
128
+ const catchClose = closingBracket(source, catchOpen);
129
+ if (catchClose === -1) continue;
130
+ blocks.push(source.slice(catchOpen + 1, catchClose));
131
+ }
132
+ for (const at of calls) {
133
+ const argsOpen = source.indexOf('(', at);
134
+ const argsClose = closingBracket(source, argsOpen);
135
+ if (argsClose === -1) continue;
136
+ const chained = /^\s*\.catch\s*\(/.exec(source.slice(argsClose + 1));
137
+ if (!chained) continue;
138
+ const handlerOpen = argsClose + 1 + chained[0].length - 1;
139
+ const handlerClose = closingBracket(source, handlerOpen);
140
+ if (handlerClose === -1) continue;
141
+ blocks.push(source.slice(handlerOpen + 1, handlerClose));
142
+ }
143
+ return blocks;
144
+ }
145
+
146
+ const callSites = serverFiles().filter((file) => callsIdentity(read(file)));
147
+ if (callSites.length === 0) process.exit(0); // pre-identity app: not this check's contract
148
+
149
+ const CONTRACT_FILES = [
150
+ ...new Set([LAYOUT_FILE, ERROR_BOUNDARY_FILE, CONTEXT_FILE, RETRY_SCREEN_FILE, ...callSites]),
151
+ ];
152
+
41
153
  const problems = [];
42
154
  if (read(RETRY_SCREEN_FILE) === null) problems.push(`${RETRY_SCREEN_FILE} is missing`);
43
- if (!/AuthUnavailableError/.test(layout)) {
44
- problems.push(`${LAYOUT_FILE} does not handle AuthUnavailableError from getSessionIdentity()`);
155
+ const boundary = read(ERROR_BOUNDARY_FILE);
156
+ if (boundary === null) {
157
+ problems.push(`${ERROR_BOUNDARY_FILE} is missing`);
158
+ } else if (!BOUNDARY_COMPARES_DIGEST.test(boundary) || !BOUNDARY_RENDERS_RETRY.test(boundary)) {
159
+ problems.push(`${ERROR_BOUNDARY_FILE} does not render <AuthUnavailable /> when error.digest is AUTH_UNAVAILABLE_DIGEST`);
45
160
  }
46
- if (read(RENEWAL_GATE_FILE) === null) problems.push(`${RENEWAL_GATE_FILE} is missing`);
47
- if (context !== null && !/createRenewalGate\s*\(/.test(context)) {
48
- problems.push(`${CONTEXT_FILE} does not bound session renewal with createRenewalGate(`);
161
+ for (const file of callSites) {
162
+ const source = read(file);
163
+ if (file === LAYOUT_FILE) {
164
+ if (!HANDLED.test(source)) {
165
+ problems.push(`${file} calls getSessionIdentity() without handling AuthUnavailableError`);
166
+ }
167
+ } else if (catchBlocksAround(source).some((block) => !CATCH_LETS_THROUGH.test(block))) {
168
+ problems.push(`${file} catches around getSessionIdentity() without rethrowing AuthUnavailableError`);
169
+ }
49
170
  }
171
+ const gateVendored = read(RENEWAL_GATE_FILE) !== null;
50
172
 
51
- if (problems.length === 0) process.exit(0);
173
+ if (problems.length === 0 && !gateVendored) process.exit(0);
52
174
 
53
- const fatal = STRICT || contractChangedThisTurn();
175
+ const fatal = (problems.length > 0 && (STRICT || contractChangedThisTurn())) || (gateVendored && STRICT);
54
176
  const out = fatal ? console.error : console.warn;
55
177
  out('');
56
- out(
57
- fatal
58
- ? 'check-identity-outage-states: the app does not handle the platform being unable to confirm the viewer:'
59
- : 'check-identity-outage-states: the app does not handle the platform being unable to confirm the viewer (pre-existing; this build is not failed for it):',
60
- );
61
- out('');
62
- for (const problem of problems) out(` - ${problem}`);
63
- out('');
64
- out('Since @assembly-js/app-foundation 0.0.40, getSessionIdentity() throws AuthUnavailableError when the');
65
- out('platform cannot be reached. Bring these app-owned files to the foundation shape:');
66
- out('');
67
- out(`1. Create ${RETRY_SCREEN_FILE}:`);
68
- out('');
69
- out(" 'use client';");
70
- out('');
71
- out(' export default function AuthUnavailable() {');
72
- out(' return (');
73
- out(' <main className="flex min-h-screen flex-col items-center justify-center gap-3 px-4 text-center">');
74
- out(' <p className="text-sm text-muted-foreground">');
75
- out(' We couldn&apos;t load this right now. Please try again.');
76
- out(' </p>');
77
- out(' <button');
78
- out(' type="button"');
79
- out(' className="rounded-md border px-3 py-1.5 text-sm"');
80
- out(' onClick={() => window.location.reload()}');
81
- out(' >');
82
- out(' Try again');
83
- out(' </button>');
84
- out(' </main>');
85
- out(' );');
86
- out(' }');
87
- out('');
88
- out(`2. In ${LAYOUT_FILE}, import it (import AuthUnavailable from '@/app/components/auth-unavailable';)`);
89
- out(' and replace `const identity = await getSessionIdentity();` with:');
90
- out('');
91
- out(' let identity: Awaited<ReturnType<typeof getSessionIdentity>> = null;');
92
- out(' let authUnavailable = false;');
93
- out(' try {');
94
- out(' identity = await getSessionIdentity();');
95
- out(' } catch (err) {');
96
- out(" if (!(err instanceof Error && err.name === 'AuthUnavailableError')) throw err;");
97
- out(' authUnavailable = true;');
98
- out(' }');
99
- out(' if (authUnavailable) {');
100
- out(' return (');
101
- out(" <html lang=\"en\" style={{ colorScheme: 'light' }}>");
102
- out(' <body className="font-sans antialiased">');
103
- out(' <AuthUnavailable />');
104
- out(' </body>');
105
- out(' </html>');
106
- out(' );');
107
- out(' }');
108
- out('');
109
- out(`3. Create ${RENEWAL_GATE_FILE}:`);
110
- out('');
111
- out(' export function createRenewalGate(');
112
- out(' renew: () => Promise<boolean>,');
113
- out(' windowMs: number,');
114
- out(' now: () => number = Date.now,');
115
- out(' ): () => Promise<boolean> {');
116
- out(' let inFlight: Promise<boolean> | null = null;');
117
- out(' let lastStartedAt = Number.NEGATIVE_INFINITY;');
118
- out(' return () => {');
119
- out(' if (inFlight) return inFlight;');
120
- out(' if (now() - lastStartedAt < windowMs) return Promise.resolve(false);');
121
- out(' lastStartedAt = now();');
122
- out(' inFlight = renew().finally(() => {');
123
- out(' inFlight = null;');
124
- out(' });');
125
- out(' return inFlight;');
126
- out(' };');
127
- out(' }');
128
- out('');
129
- out(` Then in ${CONTEXT_FILE} add \`import { createRenewalGate } from './renewal-gate';\`, define`);
130
- out(' `const renewOnce = createRenewalGate(reexchangeBearer, 30_000);` at module scope, and change the');
131
- out(" retry path's `await reexchangeBearer()` to `await renewOnce()`. Change nothing else in that file.");
178
+ if (problems.length > 0) {
179
+ out(
180
+ fatal
181
+ ? 'check-identity-outage-states: the app does not handle the platform being unable to confirm the viewer:'
182
+ : 'check-identity-outage-states: the app does not handle the platform being unable to confirm the viewer (pre-existing; this build is not failed for it):',
183
+ );
184
+ out('');
185
+ for (const problem of problems) out(` - ${problem}`);
186
+ out('');
187
+ out('Since @assembly-js/app-foundation 0.0.40, getSessionIdentity() throws AuthUnavailableError when the');
188
+ out('platform cannot be reached. Wherever the app calls it, that error must reach the retry screen:');
189
+ out('');
190
+ out(`1. Create ${RETRY_SCREEN_FILE}:`);
191
+ out('');
192
+ out(" 'use client';");
193
+ out('');
194
+ out(' export default function AuthUnavailable() {');
195
+ out(' return (');
196
+ out(' <main className="flex min-h-screen flex-col items-center justify-center gap-3 px-4 text-center">');
197
+ out(' <p className="text-sm text-muted-foreground">');
198
+ out(' We couldn&apos;t load this right now. Please try again.');
199
+ out(' </p>');
200
+ out(' <button');
201
+ out(' type="button"');
202
+ out(' className="rounded-md border px-3 py-1.5 text-sm"');
203
+ out(' onClick={() => window.location.reload()}');
204
+ out(' >');
205
+ out(' Try again');
206
+ out(' </button>');
207
+ out(' </main>');
208
+ out(' );');
209
+ out(' }');
210
+ out('');
211
+ out(`2. In ${LAYOUT_FILE}, import it (import AuthUnavailable from '@/app/components/auth-unavailable';)`);
212
+ out(' and replace `const identity = await getSessionIdentity();` with:');
213
+ out('');
214
+ out(' let identity: Awaited<ReturnType<typeof getSessionIdentity>> = null;');
215
+ out(' let authUnavailable = false;');
216
+ out(' try {');
217
+ out(' identity = await getSessionIdentity();');
218
+ out(' } catch (err) {');
219
+ out(" if (!(err instanceof Error && err.name === 'AuthUnavailableError')) throw err;");
220
+ out(' authUnavailable = true;');
221
+ out(' }');
222
+ out(' if (authUnavailable) {');
223
+ out(' return (');
224
+ out(" <html lang=\"en\" style={{ colorScheme: 'light' }}>");
225
+ out(' <body className="font-sans antialiased">');
226
+ out(' <AuthUnavailable />');
227
+ out(' </body>');
228
+ out(' </html>');
229
+ out(' );');
230
+ out(' }');
231
+ out('');
232
+ out(`3. Make ${ERROR_BOUNDARY_FILE} render the retry screen on the outage digest. Every page and nested`);
233
+ out(' layout that throws AuthUnavailableError is then covered without code of its own. If the file');
234
+ out(' exists, add the first two lines of the component body; otherwise create it:');
235
+ out('');
236
+ out(" 'use client';");
237
+ out('');
238
+ out(" import { AUTH_UNAVAILABLE_DIGEST } from '@assembly-js/app-foundation/client';");
239
+ out(" import AuthUnavailable from '@/app/components/auth-unavailable';");
240
+ out('');
241
+ out(' export default function AppError({');
242
+ out(' error,');
243
+ out(' reset,');
244
+ out(' }: {');
245
+ out(' error: Error & { digest?: string };');
246
+ out(' reset: () => void;');
247
+ out(' }) {');
248
+ out(' if (error.digest === AUTH_UNAVAILABLE_DIGEST) return <AuthUnavailable />;');
249
+ out(' return (');
250
+ out(' <main className="flex min-h-screen flex-col items-center justify-center gap-3 px-4 text-center">');
251
+ out(' <p className="text-sm text-muted-foreground">Something went wrong.</p>');
252
+ out(' <button type="button" className="rounded-md border px-3 py-1.5 text-sm" onClick={() => reset()}>');
253
+ out(' Try again');
254
+ out(' </button>');
255
+ out(' </main>');
256
+ out(' );');
257
+ out(' }');
258
+ out('');
259
+ out('4. Nothing else may swallow the error on its way to that boundary. A page, lib/ helper or route');
260
+ out(' that catches around getSessionIdentity() (try/catch or a chained .catch()) rethrows it first');
261
+ out(" (`if (err instanceof Error && err.name === 'AuthUnavailableError') throw err;`); a route handler");
262
+ out(' that returns handleRouteError(err) from its catch already answers a retryable 503 and needs no change.');
263
+ }
264
+ if (gateVendored) {
265
+ out('');
266
+ out(
267
+ fatal
268
+ ? `check-identity-outage-states: ${RENEWAL_GATE_FILE} is superseded and must be removed:`
269
+ : `check-identity-outage-states: ${RENEWAL_GATE_FILE} is superseded (pre-existing; this build is not failed for it):`,
270
+ );
271
+ out('');
272
+ out('@assembly-js/app-foundation bounds session renewal inside reexchangeBearer() since 0.5.3, so the copied');
273
+ out(`gate only adds a known flaw (it answers false inside its window even after a renewal succeeded).`);
274
+ out(`Delete ${RENEWAL_GATE_FILE} and ${RENEWAL_GATE_TEST_FILE} if present. In ${CONTEXT_FILE}, change`);
275
+ out("`await renewOnce()` back to `await reexchangeBearer()` and remove the `createRenewalGate` import and");
276
+ out('the `renewOnce` constant. Change nothing else in that file.');
277
+ }
132
278
  if (fatal) process.exit(1);
133
279
 
134
280
  function contractChangedThisTurn() {
@@ -17,11 +17,11 @@
17
17
  // none of them returns one. createAssemblyReexchangeHandler returns a route handler that
18
18
  // renews the bearer cookie; it never hands out a client.
19
19
  export const NON_CLIENT_EXPORTS = new Set([
20
- 'APP_CSRF_HEADER', 'AUTH_UNAVAILABLE_CODE', 'ActionContext', 'ActionError',
20
+ 'APP_CSRF_HEADER', 'AUTH_UNAVAILABLE_CODE', 'AUTH_UNAVAILABLE_DIGEST', 'ActionContext', 'ActionError',
21
21
  'ActionErrorStatus', 'AirtableFieldSchema', 'AppProxyCaller', 'AppProxyOptions', 'AirtableFormattedField',
22
22
  'AirtableFormattedRecord', 'AirtableRecord', 'AirtableSchemaResult', 'AirtableTableSchema',
23
23
  'AssemblyProxyForwardContext', 'AssemblyProxyOptions', 'AuthUnavailableError',
24
- 'BEARER_COOKIE_NAME', 'BEARER_HEADER_NAME', 'BEARER_IN_BODY_HEADER', 'BEARER_TTL_MS',
24
+ 'BEARER_COOKIE_NAME', 'BEARER_EXPIRY_COOKIE_NAME', 'BEARER_HEADER_NAME', 'BEARER_IN_BODY_HEADER', 'BEARER_TTL_MS',
25
25
  'CLIENT_BEARER_HEADER', 'CLIENT_FLAGS_COOKIE_NAME', 'COOKIE_OUTCOME_EVENT',
26
26
  'COOKIE_PROBE_NAME', 'Caller', 'ClearClientNotificationsInput', 'ClientAccessScope',
27
27
  'ConnectionStatus', 'ConnectorError', 'ContractDocumentError', 'ConnectorErrorCode', 'CookieOutcome', 'CronHandler',
@@ -53,9 +53,9 @@ export const NON_CLIENT_EXPORTS = new Set([
53
53
  'isValidHexColor', 'listAll', 'logCookieOutcome', 'logFailures', 'logPaintedBeacon',
54
54
  'logRenewOutcome', 'makeTestActionContext', 'mintBearerFromAppKey',
55
55
  'mintBearerFromSessionToken', 'nextRunAfter', 'normalizeResourcePage', 'notFound', 'notifyInternalUsers',
56
- 'parseExchangeRejection', 'parseUuid', 'principalKind', 'readCookie', 'recordNetworkCapture',
56
+ 'parseExchangeRejection', 'parseUuid', 'principalKind', 'readBearerExpiry', 'readCookie', 'recordNetworkCapture',
57
57
  'redactUrl', 'replaceChildRows', 'requireAuth', 'requireCompanyScope', 'resolveFont',
58
- 'resolveResource', 'sendThrottled', 'serializeBearerCookie', 'serializeCookieProbe',
58
+ 'resolveResource', 'sendThrottled', 'serializeBearerCookie', 'serializeBearerExpiryCookie', 'serializeCookieProbe',
59
59
  'setCookielessBearer', 'slackErrorCode', 'slackFindChannelId', 'slackListChannels',
60
60
  'slackPostMessage', 'slackResolveMentions', 'snippetFromRequestBody', 'verifyBearer',
61
61
  ]);
@@ -34,6 +34,7 @@ export declare class TokenInvalidError extends Error {
34
34
  */
35
35
  export declare class AuthUnavailableError extends Error {
36
36
  readonly code = "AUTH_UNAVAILABLE";
37
+ readonly digest = "ASSEMBLY_AUTH_UNAVAILABLE";
37
38
  constructor(message?: string);
38
39
  }
39
40
  export declare function getAssembly(input?: AssemblyInput): Promise<AssemblyAPI>;
package/dist/assembly.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { assemblyApi, OpenAPI } from '@assembly-js/node-sdk';
2
2
  import { cookies, headers } from 'next/headers';
3
3
  import { after, NextResponse } from 'next/server';
4
- import { BEARER_COOKIE_NAME, BEARER_HEADER_NAME, decodeBearer, isSessionExchangeEnabled, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
4
+ import { BEARER_COOKIE_NAME, BEARER_HEADER_NAME, decodeBearer, isSessionExchangeEnabled, TOKEN_EXPIRED_HEADER, AUTH_UNAVAILABLE_DIGEST, } from './session-exchange.js';
5
5
  import { mintBearerFromAppKey } from './mint-bearer.js';
6
6
  import { verifyBearer } from './verify-bearer.js';
7
7
  // Trusted API origin for this stack, supplied by the build environment — NOT by
@@ -149,6 +149,8 @@ export class TokenInvalidError extends Error {
149
149
  */
150
150
  export class AuthUnavailableError extends Error {
151
151
  code = 'AUTH_UNAVAILABLE';
152
+ // Survives production redaction, unlike name/code: see session-exchange.ts.
153
+ digest = AUTH_UNAVAILABLE_DIGEST;
152
154
  constructor(message = 'Assembly could not verify the session right now') {
153
155
  super(message);
154
156
  this.name = 'AuthUnavailableError';
@@ -11,6 +11,13 @@ export declare function downloadViaBlob(url: string, filename: string): Promise<
11
11
  * Exported for tests; runs once at module load in the browser.
12
12
  */
13
13
  export declare function bootstrapCookielessBearer(): Promise<boolean>;
14
+ /**
15
+ * Arms one timer for RENEWAL_LEAD_MS before the expiry in the cookie, replacing
16
+ * any pending one. Exported for tests; runs at module load and, with
17
+ * afterRenewal, after every successful renewal.
18
+ */
19
+ export declare function scheduleProactiveRenewal(afterRenewal?: boolean): void;
20
+ export declare function handleVisibilityChangeForTests(): void;
14
21
  /**
15
22
  * Returns an onClick handler that navigates the parent Assembly portal to the
16
23
  * given typed destination. Use in button or anchor onClick props.
@@ -6,7 +6,7 @@
6
6
  // (proxy.ts pulls constants from that barrel).
7
7
  import { AssemblyBridge } from '@assembly-js/app-bridge';
8
8
  import { APP_CSRF_HEADER, COOKIE_PROBE_NAME, logRenewOutcome, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
9
- import { readCookie } from './origin-bootstrap.js';
9
+ import { readBearerExpiry, readCookie } from './origin-bootstrap.js';
10
10
  import { cookielessReexchangeHeaders, cookiesAreBlocked, getCookielessBearer, setCookielessBearer, } from './cookieless-bearer.js';
11
11
  const REEXCHANGE_PATH = '/api/assembly-reexchange';
12
12
  // Single-flight gate for renewals. Session tokens are single-use for exchange:
@@ -37,6 +37,9 @@ export function resetRenewalStateForTests() {
37
37
  renewalInFlight = null;
38
38
  lastVerdict = null;
39
39
  probeEverSet = false;
40
+ clearProactiveRenewal();
41
+ scheduledExpiry = null;
42
+ expiryEverSeen = false;
40
43
  }
41
44
  // A late 401 response carries the proxy's cookie deletion and can erase the
42
45
  // bearer a sibling call just renewed. The probe is cleared together with the
@@ -70,6 +73,10 @@ export function reexchangeBearer() {
70
73
  .then(({ ok, sent }) => {
71
74
  probeIsSet();
72
75
  lastVerdict = sent ? { at: Date.now(), ok, probeSeen: probeEverSet } : null;
76
+ // The route re-set the expiry cookie, so the next proactive renewal is
77
+ // timed from it whichever path asked for this one.
78
+ if (ok)
79
+ scheduleProactiveRenewal(true);
73
80
  return ok;
74
81
  })
75
82
  .finally(() => {
@@ -215,6 +222,103 @@ export function bootstrapCookielessBearer() {
215
222
  // Self-running, mirroring origin-bootstrap: module evaluation precedes
216
223
  // hydration, so the bearer is in hand before any component's first fetch.
217
224
  void bootstrapCookielessBearer();
225
+ // Proactive renewal. The reactive paths above only mint a bearer after a fetch
226
+ // or action has already failed on its expiry; a server-rendered navigation
227
+ // never fails that way, it renders as no viewer. So a tab that stays open
228
+ // renews shortly before the expiry the proxy mirrors into the client-readable
229
+ // cookie, while it is visible. Without that cookie (an older proxy, the
230
+ // cookieless path) nothing is scheduled and the reactive paths stay the only
231
+ // renewal, as before.
232
+ const RENEWAL_LEAD_MS = 2 * 60_000;
233
+ const MIN_RENEWAL_DELAY_MS = 5_000;
234
+ // A timed renewal that fails (a network blip at minute 58) is retried at this
235
+ // pace until the bearer's expiry passes, so a brief outage inside the lead does
236
+ // not cost the tab its sign-in. Matches the renewal window, so each retry is a
237
+ // real POST rather than the cached verdict.
238
+ const RENEWAL_RETRY_MS = RENEWAL_WINDOW_MS;
239
+ let renewalTimer = null;
240
+ // The expiry the pending (or last fired) timer was taken from, so a successful
241
+ // renewal whose route never refreshed the cookie (a hand-written reexchange
242
+ // route) does not re-arm a timer on the same stale value every few seconds.
243
+ let scheduledExpiry = null;
244
+ // Whether this page has ever read the cookie. Only then can its absence mean
245
+ // the bearer lapsed (a laptop closed past expiry); a proxy that predates the
246
+ // cookie never sets it, and must not be renewed on every tab switch.
247
+ let expiryEverSeen = false;
248
+ function clearProactiveRenewal() {
249
+ if (renewalTimer !== null)
250
+ clearTimeout(renewalTimer);
251
+ renewalTimer = null;
252
+ }
253
+ const tabIsVisible = () => typeof document === 'undefined' || document.visibilityState !== 'hidden';
254
+ /**
255
+ * Arms one timer for RENEWAL_LEAD_MS before the expiry in the cookie, replacing
256
+ * any pending one. Exported for tests; runs at module load and, with
257
+ * afterRenewal, after every successful renewal.
258
+ */
259
+ export function scheduleProactiveRenewal(afterRenewal = false) {
260
+ if (typeof window === 'undefined')
261
+ return;
262
+ clearProactiveRenewal();
263
+ const expiresAt = readBearerExpiry();
264
+ if (expiresAt === null)
265
+ return;
266
+ expiryEverSeen = true;
267
+ if (afterRenewal && expiresAt === scheduledExpiry)
268
+ return;
269
+ scheduledExpiry = expiresAt;
270
+ const delay = Math.max(MIN_RENEWAL_DELAY_MS, expiresAt - RENEWAL_LEAD_MS - Date.now());
271
+ renewalTimer = setTimeout(renewOnTimer, delay);
272
+ }
273
+ function renewOnTimer() {
274
+ renewalTimer = null;
275
+ // A hidden tab has no bridge parent to refresh the session token from and
276
+ // nothing to render; the visibility handler renews it on return.
277
+ if (!tabIsVisible())
278
+ return;
279
+ void reexchangeBearer().then((renewed) => {
280
+ // Success re-armed the timer from the refreshed cookie. A failure retries
281
+ // while the current bearer is still alive; once it has expired the
282
+ // reactive paths and the visibility handler take over, as before.
283
+ if (renewed || renewalTimer !== null)
284
+ return;
285
+ const expiresAt = readBearerExpiry();
286
+ // A retry inside the window would only get the cached verdict back.
287
+ if (expiresAt === null || expiresAt - Date.now() <= RENEWAL_RETRY_MS)
288
+ return;
289
+ renewalTimer = setTimeout(renewOnTimer, RENEWAL_RETRY_MS);
290
+ });
291
+ }
292
+ // A tab that comes back after the lead, or after the cookie itself lapsed, gets
293
+ // a fresh bearer now. The page it shows keeps its stale render until the next
294
+ // navigation, which then lands signed in; forcing a reload is deliberately not
295
+ // done here.
296
+ function renewOnVisible() {
297
+ if (!tabIsVisible())
298
+ return;
299
+ const expiresAt = readBearerExpiry();
300
+ if (expiresAt === null) {
301
+ if (expiryEverSeen)
302
+ void reexchangeBearer();
303
+ return;
304
+ }
305
+ expiryEverSeen = true;
306
+ if (expiresAt - Date.now() < RENEWAL_LEAD_MS) {
307
+ void reexchangeBearer();
308
+ }
309
+ else if (renewalTimer === null) {
310
+ scheduleProactiveRenewal();
311
+ }
312
+ }
313
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
314
+ scheduleProactiveRenewal();
315
+ document.addEventListener('visibilitychange', renewOnVisible);
316
+ }
317
+ // Test seam for the visibility handler, which is otherwise reachable only
318
+ // through the real document event.
319
+ export function handleVisibilityChangeForTests() {
320
+ renewOnVisible();
321
+ }
218
322
  /**
219
323
  * Returns an onClick handler that navigates the parent Assembly portal to the
220
324
  * given typed destination. Use in button or anchor onClick props.
@@ -11,6 +11,12 @@ export declare function readCookie(name: string, cookieString?: string): string
11
11
  * Exported so client modules can gate on a flag without re-parsing the cookie.
12
12
  */
13
13
  export declare function hasClientFlag(key: string, cookieString?: string): boolean;
14
+ /**
15
+ * When the current bearer expires, as epoch milliseconds, from the cookie the
16
+ * proxy and the reexchange route mirror beside it. Null when no such cookie is
17
+ * readable (older proxy, cookieless browser, or already expired).
18
+ */
19
+ export declare function readBearerExpiry(cookieString?: string): number | null;
14
20
  /**
15
21
  * Register the workspace's portal origin with the bridge allowlist — from the
16
22
  * proxy-set cookie when it is readable, from the framing origin otherwise.
@@ -20,6 +20,7 @@
20
20
  // domains are built into the bridge's defaults). Nothing resolvable → no-op:
21
21
  // the app still works on every default-allowlisted surface.
22
22
  import { AssemblyBridge } from '@assembly-js/app-bridge';
23
+ import { BEARER_EXPIRY_COOKIE_NAME } from './session-exchange.js';
23
24
  export const PORTAL_ORIGIN_COOKIE_NAME = 'assembly-portal-origin';
24
25
  // Mirrors the bearer's `flags` claim (see session-exchange.ts's
25
26
  // ExchangedSession.flags / hasFlag) — the bearer itself is HttpOnly and
@@ -56,6 +57,18 @@ export function hasClientFlag(key, cookieString) {
56
57
  return false;
57
58
  return raw.split(',').includes(key);
58
59
  }
60
+ /**
61
+ * When the current bearer expires, as epoch milliseconds, from the cookie the
62
+ * proxy and the reexchange route mirror beside it. Null when no such cookie is
63
+ * readable (older proxy, cookieless browser, or already expired).
64
+ */
65
+ export function readBearerExpiry(cookieString) {
66
+ const raw = readCookie(BEARER_EXPIRY_COOKIE_NAME, cookieString);
67
+ if (!raw)
68
+ return null;
69
+ const seconds = Number(raw);
70
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1_000 : null;
71
+ }
59
72
  /**
60
73
  * Framing-origin resolution for when the portal-origin cookie is unreadable: `location.ancestorOrigins`
61
74
  * (browser-attested; Chromium/WebKit), falling back to `document.referrer`
package/dist/proxy.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // adds its own extras through onForward.
6
6
  import { assemblyApi } from '@assembly-js/node-sdk';
7
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';
8
+ import { APP_CSRF_HEADER, BEARER_COOKIE_NAME, BEARER_HEADER_NAME, CLIENT_BEARER_HEADER, decodeBearer, isDocumentRequest, isSessionExchangeEnabled, logCookieOutcome, serializeBearerCookie, serializeBearerExpiryCookie, serializeCookieProbe, } from './session-exchange.js';
9
9
  import { CLIENT_FLAGS_COOKIE_NAME } from './origin-bootstrap.js';
10
10
  import { handlePaintedBeacon, isPaintedBeaconPath } from './paint-beacon.js';
11
11
  import { fetchSessionFlags, mintBearerFromAppKey, mintBearerFromSessionToken, } from './mint-bearer.js';
@@ -594,11 +594,13 @@ async function handleRequest(request, options) {
594
594
  response.headers.append('Set-Cookie', serializeBearerCookie('', 0, false));
595
595
  response.headers.append('Set-Cookie', serializeBearerCookie(newBearer, maxAge, true));
596
596
  response.headers.append('Set-Cookie', serializeCookieProbe(true));
597
+ response.headers.append('Set-Cookie', serializeBearerExpiryCookie(newBearerExpiresAt, maxAge));
597
598
  }
598
599
  else if (clearBearerCookie) {
599
600
  response.headers.append('Set-Cookie', serializeBearerCookie('', 0, false));
600
601
  response.headers.append('Set-Cookie', serializeBearerCookie('', 0, true));
601
602
  response.headers.append('Set-Cookie', serializeCookieProbe(true, true));
603
+ response.headers.append('Set-Cookie', serializeBearerExpiryCookie(0, 0));
602
604
  }
603
605
  else if (bearerCookie && isDocumentRequest(request)) {
604
606
  // Refresh the probe on a document served from a live cookie: it outlives the
@@ -2,7 +2,7 @@
2
2
  // each app because it is security-critical code an agent must never re-author;
3
3
  // an app's route file only calls createAssemblyReexchangeHandler.
4
4
  import { NextResponse } from 'next/server';
5
- import { APP_CSRF_HEADER, BEARER_IN_BODY_HEADER, isSessionExchangeEnabled, serializeBearerCookie, serializeCookieProbe, } from './session-exchange.js';
5
+ import { APP_CSRF_HEADER, BEARER_IN_BODY_HEADER, isSessionExchangeEnabled, serializeBearerCookie, serializeBearerExpiryCookie, serializeCookieProbe, } from './session-exchange.js';
6
6
  import { mintBearerFromSessionToken } from './mint-bearer.js';
7
7
  // The only header any route may read a session token from.
8
8
  const SESSION_TOKEN_HEADER = 'x-assembly-token';
@@ -50,6 +50,8 @@ export function createAssemblyReexchangeHandler() {
50
50
  response.headers.append('Set-Cookie', serializeBearerCookie('', 0, false));
51
51
  response.headers.append('Set-Cookie', serializeBearerCookie(minted.bearer, maxAge, true));
52
52
  response.headers.append('Set-Cookie', serializeCookieProbe(true));
53
+ // The client schedules its next proactive renewal from this.
54
+ response.headers.append('Set-Cookie', serializeBearerExpiryCookie(minted.bearerExpiresAt, maxAge));
53
55
  return response;
54
56
  };
55
57
  }
@@ -9,6 +9,9 @@ export declare const CLIENT_BEARER_HEADER = "x-assembly-client-bearer";
9
9
  export declare const BEARER_IN_BODY_HEADER = "x-assembly-bearer-in-body";
10
10
  export declare const COOKIE_PROBE_NAME = "assembly-cookie-probe";
11
11
  export declare function serializeCookieProbe(partitioned: boolean, clear?: boolean): string;
12
+ export declare const BEARER_EXPIRY_COOKIE_NAME = "assembly-bearer-exp";
13
+ export declare function serializeBearerExpiryCookie(expiresAtMs: number, maxAgeSeconds: number): string;
14
+ export declare const AUTH_UNAVAILABLE_DIGEST = "ASSEMBLY_AUTH_UNAVAILABLE";
12
15
  export declare const TOKEN_EXPIRED_HEADER = "x-assembly-token-expired";
13
16
  export declare const EXCHANGE_REJECTION_HEADER = "x-assembly-exchange-rejection";
14
17
  export type ExchangeRejectedViewer = 'same_viewer' | 'different_viewer';
@@ -77,6 +77,21 @@ export function serializeCookieProbe(partitioned, clear = false) {
77
77
  cookie += '; Partitioned';
78
78
  return cookie;
79
79
  }
80
+ // Client-readable twin of the bearer's expiry. The bearer is HttpOnly, so this
81
+ // is how the browser learns when to renew it before a server-rendered
82
+ // navigation would find it expired. Epoch seconds, not a secret; written and
83
+ // cleared in lockstep with the bearer, attributes as the other client-readable
84
+ // cookies (Partitioned, no HttpOnly).
85
+ export const BEARER_EXPIRY_COOKIE_NAME = 'assembly-bearer-exp';
86
+ export function serializeBearerExpiryCookie(expiresAtMs, maxAgeSeconds) {
87
+ const value = maxAgeSeconds > 0 ? String(Math.floor(expiresAtMs / 1_000)) : '';
88
+ return `${BEARER_EXPIRY_COOKIE_NAME}=${value}; Path=/; Secure; SameSite=None; Max-Age=${maxAgeSeconds}; Partitioned`;
89
+ }
90
+ // Digest AuthUnavailableError carries so a client error boundary can recognise
91
+ // an outage. Production redacts a server error's name, code and message before
92
+ // the boundary sees it, but Next keeps a digest the error already has; this is
93
+ // the one field that survives, so app/error.tsx matches on it.
94
+ export const AUTH_UNAVAILABLE_DIGEST = 'ASSEMBLY_AUTH_UNAVAILABLE';
80
95
  // Marker header on 401s that mean "the bearer expired — re-exchange and retry",
81
96
  // emitted by requireAuth / getAssemblyForRequest / the re-exchange route. Its
82
97
  // absence on a denial is what lets authFetch skip renewal for genuine
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [