@assembly-js/app-foundation 0.5.4 → 0.6.1
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 +7 -0
- package/bin/check-identity-outage-states.mjs +245 -99
- package/bin/client-safe-sdk-methods.mjs +9 -3
- package/bin/foundation-exports.mjs +1 -1
- package/dist/assembly.d.ts +1 -0
- package/dist/assembly.js +3 -1
- package/dist/session-exchange.d.ts +1 -0
- package/dist/session-exchange.js +5 -0
- package/package.json +1 -1
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.1
|
|
23
|
+
- [fix] The client-call scan no longer reports a `fetch` to the AI proxy (`POST /v1/ai/openai/...`) as breaking: the platform now admits a client bearer on that route. Nothing in the app changes.
|
|
24
|
+
|
|
25
|
+
## 0.6.0
|
|
26
|
+
- [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.
|
|
27
|
+
- [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.
|
|
28
|
+
|
|
22
29
|
## 0.5.4
|
|
23
30
|
- [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.
|
|
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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
13
|
-
//
|
|
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
|
|
17
|
-
//
|
|
18
|
-
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
out(
|
|
64
|
-
out('
|
|
65
|
-
out('
|
|
66
|
-
out('');
|
|
67
|
-
out(
|
|
68
|
-
out(
|
|
69
|
-
out(
|
|
70
|
-
out('');
|
|
71
|
-
out('
|
|
72
|
-
out('
|
|
73
|
-
out('
|
|
74
|
-
out('
|
|
75
|
-
out('
|
|
76
|
-
out('
|
|
77
|
-
out('
|
|
78
|
-
out('
|
|
79
|
-
out('
|
|
80
|
-
out('
|
|
81
|
-
out('
|
|
82
|
-
out('
|
|
83
|
-
out('
|
|
84
|
-
out('
|
|
85
|
-
out('
|
|
86
|
-
out('
|
|
87
|
-
out('');
|
|
88
|
-
out(
|
|
89
|
-
out(
|
|
90
|
-
out('');
|
|
91
|
-
out('
|
|
92
|
-
out(' let
|
|
93
|
-
out('
|
|
94
|
-
out('
|
|
95
|
-
out('
|
|
96
|
-
out(
|
|
97
|
-
out(
|
|
98
|
-
out('
|
|
99
|
-
out('
|
|
100
|
-
out('
|
|
101
|
-
out(
|
|
102
|
-
out(
|
|
103
|
-
out('
|
|
104
|
-
out('
|
|
105
|
-
out('
|
|
106
|
-
out('
|
|
107
|
-
out('
|
|
108
|
-
out('');
|
|
109
|
-
out(
|
|
110
|
-
out(
|
|
111
|
-
out('
|
|
112
|
-
out('
|
|
113
|
-
out('
|
|
114
|
-
out('
|
|
115
|
-
out('
|
|
116
|
-
out(
|
|
117
|
-
out(
|
|
118
|
-
out('
|
|
119
|
-
out('
|
|
120
|
-
out('
|
|
121
|
-
out('
|
|
122
|
-
out('
|
|
123
|
-
out('
|
|
124
|
-
out('
|
|
125
|
-
out('
|
|
126
|
-
out('
|
|
127
|
-
out('
|
|
128
|
-
out('');
|
|
129
|
-
out(
|
|
130
|
-
out('
|
|
131
|
-
out(
|
|
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'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() {
|
|
@@ -14,6 +14,7 @@ export const CLIENT_SAFE_ROUTES = Object.freeze([
|
|
|
14
14
|
{ method: 'POST', path: '/v1/notifications/{id}/read', sdkMethod: 'markNotificationRead' },
|
|
15
15
|
{ method: 'POST', path: '/v1/notifications/{id}/unread', sdkMethod: 'markNotificationUnread' },
|
|
16
16
|
{ method: 'GET', path: '/v1/session/flags', sdkMethod: null },
|
|
17
|
+
{ method: 'POST', path: '/v1/ai/openai/*proxyPath', sdkMethod: null }, // the AI proxy; reached through `fetch`
|
|
17
18
|
].map(Object.freeze));
|
|
18
19
|
|
|
19
20
|
// retrieveClient is allowlisted by name: whether the id is the viewer's own is a runtime
|
|
@@ -27,10 +28,15 @@ export const CLIENT_SAFE_SDK_METHODS = Object.freeze(
|
|
|
27
28
|
// Marked client-safe routes that no SDK method reaches yet.
|
|
28
29
|
export const CLIENT_SAFE_ROUTES_WITHOUT_SDK_METHOD = CLIENT_SAFE_ROUTES.filter((r) => !r.sdkMethod).length;
|
|
29
30
|
|
|
30
|
-
// `{id}` stands for exactly one non-empty path segment
|
|
31
|
+
// `{id}` stands for exactly one non-empty path segment; a trailing `*name` stands for the rest of
|
|
32
|
+
// the path, as the platform's wildcard does.
|
|
31
33
|
// The id segment excludes `.` and `%` so `.`, `..` and `%2e` forms that URL normalisation resolves elsewhere fail closed.
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
+
const segmentPattern = (seg) => {
|
|
35
|
+
if (seg === '{id}') return '[A-Za-z0-9_-]+';
|
|
36
|
+
if (seg.startsWith('*')) return '.+';
|
|
37
|
+
return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
38
|
+
};
|
|
39
|
+
const routePattern = (route) => new RegExp(`^${route.path.split('/').map(segmentPattern).join('/')}$`);
|
|
34
40
|
const ROUTE_MATCHERS = CLIENT_SAFE_ROUTES.map((route) => ({ method: route.method, pattern: routePattern(route) }));
|
|
35
41
|
|
|
36
42
|
// Whether `fetch(path, { method })` hits a client-safe route; `path` has no query string.
|
|
@@ -17,7 +17,7 @@
|
|
|
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',
|
package/dist/assembly.d.ts
CHANGED
|
@@ -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,7 @@ export declare const COOKIE_PROBE_NAME = "assembly-cookie-probe";
|
|
|
11
11
|
export declare function serializeCookieProbe(partitioned: boolean, clear?: boolean): string;
|
|
12
12
|
export declare const BEARER_EXPIRY_COOKIE_NAME = "assembly-bearer-exp";
|
|
13
13
|
export declare function serializeBearerExpiryCookie(expiresAtMs: number, maxAgeSeconds: number): string;
|
|
14
|
+
export declare const AUTH_UNAVAILABLE_DIGEST = "ASSEMBLY_AUTH_UNAVAILABLE";
|
|
14
15
|
export declare const TOKEN_EXPIRED_HEADER = "x-assembly-token-expired";
|
|
15
16
|
export declare const EXCHANGE_REJECTION_HEADER = "x-assembly-exchange-rejection";
|
|
16
17
|
export type ExchangeRejectedViewer = 'same_viewer' | 'different_viewer';
|
package/dist/session-exchange.js
CHANGED
|
@@ -87,6 +87,11 @@ export function serializeBearerExpiryCookie(expiresAtMs, maxAgeSeconds) {
|
|
|
87
87
|
const value = maxAgeSeconds > 0 ? String(Math.floor(expiresAtMs / 1_000)) : '';
|
|
88
88
|
return `${BEARER_EXPIRY_COOKIE_NAME}=${value}; Path=/; Secure; SameSite=None; Max-Age=${maxAgeSeconds}; Partitioned`;
|
|
89
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';
|
|
90
95
|
// Marker header on 401s that mean "the bearer expired — re-exchange and retry",
|
|
91
96
|
// emitted by requireAuth / getAssemblyForRequest / the re-exchange route. Its
|
|
92
97
|
// absence on a denial is what lets authFetch skip renewal for genuine
|