@assembly-js/app-foundation 0.0.54 → 0.0.55

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
@@ -14,6 +14,9 @@ A foundation update reads the sections between an app's version and the target:
14
14
  from every bullet except `[guidance]`, agent work from the check scripts those `[action]`
15
15
  bullets ship. Write `[fix]`, `[behavior]` and `[action]` bullets for the app's owner.
16
16
 
17
+ ## 0.0.55
18
+ - [new] Add a scan the platform can run on demand to find calls that use a signed-in viewer's access to the platform API from pages and routes clients can open. Nothing in the app changes and no file is added to it.
19
+
17
20
  ## 0.0.54
18
21
  - [new] Add `isAppInstalled` and `getInstalledAppIds`, helpers an app can adopt to check which optional modules are installed in its workspace.
19
22
  - [guidance] The build agent is taught to check that a module is installed before relying on features that depend on it.
@@ -0,0 +1,11 @@
1
+ // PROVISIONAL until the final client-safe public route list lands.
2
+ //
3
+ // The routes a client bearer may keep calling once clients stop acting as admin,
4
+ // as SDK method names (the SDK is flat: `retrieveClient`, not `clients.retrieve`):
5
+ // GET /v1/me -> retrieveMe
6
+ // GET /v1/clients/{id} -> retrieveClient (the client's own record only)
7
+ // GET /v1/session/flags -> no SDK method yet, so nothing to list; add it
8
+ // here when the SDK gains one.
9
+ // retrieveClient is allowlisted by name: whether the id is the viewer's own is a
10
+ // runtime fact a static scan cannot see.
11
+ export const CLIENT_SAFE_SDK_METHODS = Object.freeze(['retrieveClient', 'retrieveMe']);
@@ -0,0 +1,59 @@
1
+ // Every name `@assembly-js/app-foundation/server` and `/client` export that is NOT a way to
2
+ // obtain an Assembly client or request context for the person using the app. The scan
3
+ // follows viewer clients only from the sources it knows (SDK_FACTORIES, afterAssembly,
4
+ // getRequestContext in scan-client-calls.mjs); an import from those entry points that is in
5
+ // neither place is something it cannot classify, so it records a defeater instead of
6
+ // assuming the name is harmless.
7
+ //
8
+ // scan-client-calls.test.mjs checks this list against the package's real exports: a new
9
+ // export fails there until it is classified, and one whose signature returns or hands out
10
+ // an AssemblyAPI or ActionContext cannot be listed here. Classify it as a source in the
11
+ // scanner, not in this list.
12
+ //
13
+ // automationActionRoute hands its handler an ActionContext, but that context is the platform's
14
+ // automation caller (an app), never a viewer. makeTestActionContext builds a fake context for
15
+ // tests that no request can reach. getClientAccessScope, getInternalUserAccess,
16
+ // requireCompanyScope, getInstalledAppIds and isAppInstalled take a client as an argument;
17
+ // none of them returns one.
18
+ export const NON_CLIENT_EXPORTS = new Set([
19
+ 'APP_CSRF_HEADER', 'AUTH_UNAVAILABLE_CODE', 'ActionContext', 'ActionError',
20
+ 'ActionErrorStatus', 'AirtableFieldSchema', 'AirtableFormattedField',
21
+ 'AirtableFormattedRecord', 'AirtableRecord', 'AirtableSchemaResult', 'AirtableTableSchema',
22
+ 'AssemblyProxyForwardContext', 'AssemblyProxyOptions', 'AuthUnavailableError',
23
+ 'BEARER_COOKIE_NAME', 'BEARER_HEADER_NAME', 'BEARER_IN_BODY_HEADER', 'BEARER_TTL_MS',
24
+ 'CLIENT_BEARER_HEADER', 'CLIENT_FLAGS_COOKIE_NAME', 'COOKIE_OUTCOME_EVENT',
25
+ 'COOKIE_PROBE_NAME', 'Caller', 'ClearClientNotificationsInput', 'ClientAccessScope',
26
+ 'ConnectionStatus', 'ConnectorError', 'ConnectorErrorCode', 'CookieOutcome', 'CronHandler',
27
+ 'DEFAULT_LABELS', 'EXCHANGE_REJECTION_HEADER', 'ExchangeRejectedViewer', 'ExchangedSession',
28
+ 'FormatAirtableRecordsOpts', 'GmailSendArgs', 'GmailSendResult', 'IntegrationClient',
29
+ 'InternalUserAccess', 'LEGACY_APP_IDS', 'MintFailure', 'MintOutcome', 'MintSessionOptions', 'MintedAppBearer',
30
+ 'MintedBearer', 'NETWORK_CAPTURE_MESSAGE_TYPE', 'NetworkCaptureHeaderFingerprint',
31
+ 'NetworkCaptureRecord', 'PAINTED_BEACON_EVENT', 'PAINTED_BEACON_PATH',
32
+ 'PORTAL_ORIGIN_COOKIE_NAME', 'PREVIEW_V2_FLAG', 'PdfText', 'PrincipalAllow', 'PrincipalKind',
33
+ 'Provider', 'ProxyOpts', 'RENEW_OUTCOME_EVENT', 'RenewOutcome', 'ReplaceChildRowsResult',
34
+ 'RequestSource', 'ResolveResourceOpts', 'ResourceError', 'ResourceFieldMap',
35
+ 'ResourceOption', 'ResourcePage', 'ScheduleCadence', 'SlackChannel',
36
+ 'SlackPostMessageResult', 'TOKEN_EXPIRED_HEADER', 'TokenInvalidError', 'TokenPayload',
37
+ 'TtlCache', 'VERIFY_OUTCOME_EVENT', 'VERIFY_PATH', 'VIEWER_BEARER_HEADER',
38
+ 'VerifiedIdentity', 'VerifyResult', 'Viewer', 'WorkspaceLabels', 'authUnavailableResponse',
39
+ 'automationActionRoute', 'badRequest', 'bootstrapPortalOrigin', 'buildMimeMessage',
40
+ 'canAccessClient', 'canAccessCompanies', 'classifyExchangeFailure',
41
+ 'clearClientNotifications', 'collectHeaders', 'collectXhrResponseHeaders',
42
+ 'cookielessReexchangeHeaders', 'cookiesAreBlocked', 'createAssemblyProxy', 'cronRoute',
43
+ 'decodeBearer', 'expandClientsByCompany', 'extractPdfText', 'fetchSessionFlags',
44
+ 'findResource', 'formatAirtableRecords', 'getAirtableTableSchema', 'getCapturedRecords',
45
+ 'getClientAccessScope', 'getCookielessBearer', 'getDownloadUrl', 'getInstalledAppIds', 'getIntegration',
46
+ 'getInternalUserAccess', 'getLabels', 'getSessionIdentity', 'getUploadUrl',
47
+ 'getVerifiedIdentity', 'gmailSendEmail', 'handlePaintedBeacon', 'handleRouteError',
48
+ 'hasClientFlag', 'hasFlag', 'installFetchCapture', 'installNetworkCapture',
49
+ 'installPaintBeacon', 'installXhrCapture', 'internalError', 'isAppInstalled', 'isConnected', 'isCronRequest',
50
+ 'isDocumentRequest', 'isPaintedBeaconPath', 'isSessionExchangeEnabled', 'isUuid',
51
+ 'isValidHexColor', 'listAll', 'logCookieOutcome', 'logFailures', 'logPaintedBeacon',
52
+ 'logRenewOutcome', 'makeTestActionContext', 'mintBearerFromAppKey',
53
+ 'mintBearerFromSessionToken', 'nextRunAfter', 'normalizeResourcePage', 'notFound',
54
+ 'parseExchangeRejection', 'parseUuid', 'principalKind', 'readCookie', 'recordNetworkCapture',
55
+ 'redactUrl', 'replaceChildRows', 'requireAuth', 'requireCompanyScope', 'resolveFont',
56
+ 'resolveResource', 'sendThrottled', 'serializeBearerCookie', 'serializeCookieProbe',
57
+ 'setCookielessBearer', 'slackErrorCode', 'slackFindChannelId', 'slackListChannels',
58
+ 'slackPostMessage', 'slackResolveMentions', 'snippetFromRequestBody', 'verifyBearer',
59
+ ]);
@@ -0,0 +1,934 @@
1
+ // Finds platform SDK calls an app makes with a viewer's bearer from code a
2
+ // client session can reach, so apps that break once client bearers are denied by
3
+ // default can be found before the switch.
4
+ //
5
+ // A static scan can prove an app breaks but never that it is fine, so each app
6
+ // lands in one of three classes:
7
+ // BREAKS a non-allowlisted SDK method is called on a viewer-bound client
8
+ // from a client-reachable entry.
9
+ // CLEAN no such call, and nothing the scan could not see through.
10
+ // UNRESOLVED everything else: a construct defeats the analysis (a receiver
11
+ // escaping into a call, spread or dynamic property access, an
12
+ // any-typed receiver, a dynamic import), or an import could not be
13
+ // followed.
14
+ //
15
+ // The work is split so a second projection (cross-app calls) can reuse it:
16
+ // `createAnalysis` yields per-file facts (every SDK call with its provenance,
17
+ // plus the constructs that defeat analysis) and the import graph;
18
+ // `projectClientReachable` is one projection over those facts.
19
+ //
20
+ // Parse-only like scan-routes: ts.createSourceFile, no Program, no type check,
21
+ // so an app that does not typecheck still scans. Files are parsed lazily, only
22
+ // when an entry reaches them.
23
+ import { readFileSync } from 'node:fs';
24
+ import path from 'node:path';
25
+ import { CLIENT_SAFE_SDK_METHODS } from './client-safe-sdk-methods.mjs';
26
+ import { NON_CLIENT_EXPORTS } from './foundation-exports.mjs';
27
+ import {
28
+ EXCLUDING_WRAPPERS,
29
+ PROXY_METHODS,
30
+ ROUTE_FILE_PREDICATE,
31
+ analyseExports,
32
+ byteCompare,
33
+ isReservedTemplate,
34
+ listFiles,
35
+ loadTypeScript,
36
+ readPrivateRoutes,
37
+ templatesForDirs,
38
+ templatesForFile,
39
+ wrapperName,
40
+ } from './scan-routes.mjs';
41
+
42
+ export const SCANNER_VERSION = 1;
43
+
44
+ export const CLASS_BREAKS = 'BREAKS';
45
+ export const CLASS_CLEAN = 'CLEAN';
46
+ export const CLASS_UNRESOLVED = 'UNRESOLVED';
47
+
48
+ export const PROVENANCE_VIEWER = 'viewer';
49
+ // Installer and offline clients act with the app's own authority, so a call on
50
+ // them never breaks when client bearers lose admin reach.
51
+ export const PROVENANCE_INSTALLER = 'installer';
52
+ export const PROVENANCE_OFFLINE = 'offline';
53
+
54
+ // getInstallerAssembly and ctx.installer come from the installer-client work and
55
+ // may not be on every foundation version yet; the names are known either way.
56
+ export const SDK_FACTORIES = Object.freeze({
57
+ getAssembly: PROVENANCE_VIEWER,
58
+ getAssemblyForRequest: PROVENANCE_VIEWER,
59
+ getInstallerAssembly: PROVENANCE_INSTALLER,
60
+ getOfflineAssembly: PROVENANCE_OFFLINE,
61
+ });
62
+ export const CTX_MEMBERS = Object.freeze({
63
+ assembly: PROVENANCE_VIEWER,
64
+ installer: PROVENANCE_INSTALLER,
65
+ });
66
+ // afterAssembly captures getAssembly({ token }) and hands that viewer client to
67
+ // its callback; the factory list above does not mention it.
68
+ const AFTER_ASSEMBLY = 'afterAssembly';
69
+ // getRequestContext() returns the same request context a page receives as `ctx`, so
70
+ // its `assembly` and `installer` members carry the same provenance under any local name.
71
+ const REQUEST_CONTEXT = 'getRequestContext';
72
+
73
+ const SOURCE_FILE = /\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts)$/;
74
+ const DECLARATION_FILE = /\.d\.[cm]?ts$/;
75
+ const RESOLVE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'];
76
+ // Pages and the files Next loads around them: all served to whoever opens the page.
77
+ const PAGE_FILE = /^(?:src\/)?app\/((?:.+\/)?)(?:page|layout|template|default|loading|error|not-found|global-error)\.(?:ts|tsx|js|jsx)$/;
78
+ const LAYOUT_FILE = /(?:^|\/)layout\.(?:ts|tsx|js|jsx)$/;
79
+ const APP_ROOT_PREFIXES = ['app/', 'src/app/'];
80
+ // Module specifiers the scan treats as the app's own code or the platform's.
81
+ const LOCAL_SPECIFIER = /^(?:\.{1,2}(?:\/|$)|@\/|~\/)/;
82
+ const SDK_SPECIFIER = /^@assembly-js\//;
83
+ // The entry points that export the client sources. A name imported from them that is neither
84
+ // a known source nor listed as harmless is one the scan cannot classify.
85
+ const FOUNDATION_ENTRY = /^@assembly-js\/app-foundation\/(?:server|client)$/;
86
+ const ALIAS_ROOTS = ['', 'src/'];
87
+ const PROMISE_CHAIN_METHODS = new Set(['then', 'catch', 'finally']);
88
+ // The SDK is flat and its method names follow the codegen verbs (`listClients`, `retrieveMe`,
89
+ // `getV1Comments`), so a call with one of these shapes is an SDK call whatever the receiver is
90
+ // called. Recognising the method instead of the source is what keeps a client the name-based
91
+ // tagging never saw from reading CLEAN.
92
+ const SDK_METHOD_SHAPE = /^(?:(?:list|retrieve|create|update|delete|send|cancel|add|mark|request|download)[A-Z]|(?:get|put|post)V1)/;
93
+ // `fetch` is the one flat client method the verb pattern cannot match; it is how apps reach
94
+ // non-SDK endpoints such as the AI proxy.
95
+ const SDK_METHOD_EXTRAS = new Set(['fetch']);
96
+ // Browser and Node methods that fit the verb pattern on any receiver (a media query list, a
97
+ // ref, a form), so a call on one says nothing about the SDK.
98
+ const NON_SDK_METHODS = new Set([
99
+ 'addEventListener', 'removeEventListener', 'requestSubmit', 'requestPermission',
100
+ 'requestFullscreen', 'requestAnimationFrame', 'cancelAnimationFrame', 'createReadStream',
101
+ 'createWriteStream',
102
+ ]);
103
+ // Platform objects whose verb-shaped methods (`URL.createObjectURL`, `document.createElement`)
104
+ // are not SDK calls.
105
+ const NON_SDK_RECEIVERS = new Set([
106
+ 'URL', 'document', 'window', 'React', 'Object', 'Array', 'JSON', 'Promise', 'Math', 'Intl',
107
+ 'console', 'crypto', 'navigator', 'globalThis', 'self', 'Reflect', 'Symbol', 'Number', 'String', 'Date',
108
+ ]);
109
+
110
+ export const DEFEATER = Object.freeze({
111
+ ESCAPES: 'receiver escapes into a call or value the scan cannot follow',
112
+ SPREAD: 'receiver is spread',
113
+ DYNAMIC_ACCESS: 'receiver member accessed dynamically',
114
+ METHOD_REFERENCE: 'receiver method used as a value instead of called',
115
+ DESTRUCTURED: 'receiver is destructured',
116
+ ANY_TYPED: 'receiver is typed any',
117
+ REASSIGNED: 'receiver variable is reassigned',
118
+ PROMISE_CHAIN: 'receiver reached through a promise chain',
119
+ FACTORY_VALUE: 'SDK factory used as a value instead of called',
120
+ FACTORY_ALIAS: 'SDK factory re-exported under another name',
121
+ DYNAMIC_IMPORT: 'dynamic import the scan cannot resolve',
122
+ UNRESOLVED_IMPORT: 'local import could not be resolved to a file',
123
+ UNKNOWN_SOURCE: 'SDK export the scan does not classify',
124
+ UNTRACKED_SDK_METHOD: 'SDK-shaped method called on a receiver the scan could not trace to a client',
125
+ });
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Per-file facts
129
+ // ---------------------------------------------------------------------------
130
+
131
+ // The names a viewer client can come from: the viewer factories, afterAssembly's callback and
132
+ // the request context getter.
133
+ const isViewerSource = (name) =>
134
+ SDK_FACTORIES[name] === PROVENANCE_VIEWER || name === AFTER_ASSEMBLY || name === REQUEST_CONTEXT;
135
+
136
+ const lineOf = (sf, node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
137
+
138
+ class Scope {
139
+ constructor(parent) {
140
+ this.parent = parent;
141
+ this.bindings = new Map(); // name -> { provenance | null }
142
+ }
143
+ lookup(name) {
144
+ for (let s = this; s; s = s.parent) if (s.bindings.has(name)) return s.bindings.get(name);
145
+ return undefined;
146
+ }
147
+ }
148
+
149
+ function scriptKind(ts, file) {
150
+ if (/\.(?:tsx|jsx)$/.test(file)) return ts.ScriptKind.TSX;
151
+ return /\.[cm]?js$/.test(file) ? ts.ScriptKind.JS : ts.ScriptKind.TS;
152
+ }
153
+
154
+ // What one file does with SDK clients, independent of any entry or allowlist:
155
+ // `calls` are every method call on a tagged receiver, `defeaters` are the
156
+ // constructs that stop the scan following a viewer receiver, `specifiers` the
157
+ // module specifiers it imports.
158
+ export function collectFileFacts(ts, file, text) {
159
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind(ts, file));
160
+ const calls = [];
161
+ const defeaters = [];
162
+ const specifiers = [];
163
+ const factoryLocals = new Map(); // local name -> provenance
164
+ const namespaceLocals = new Set();
165
+ const afterAssemblyLocals = new Set();
166
+ const requestContextLocals = new Set();
167
+ const callbackViewerParams = new WeakSet();
168
+ const declared = new WeakSet();
169
+
170
+ const defeat = (node, reason) => defeaters.push({ line: lineOf(sf, node), reason });
171
+
172
+ const isLiteralText = (n) => n && (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n));
173
+
174
+ // --- imports ------------------------------------------------------------
175
+ const acceptsFactories = (spec) => LOCAL_SPECIFIER.test(spec) || SDK_SPECIFIER.test(spec);
176
+ for (const stmt of sf.statements) {
177
+ if (ts.isImportDeclaration(stmt) && ts.isStringLiteral(stmt.moduleSpecifier)) {
178
+ const spec = stmt.moduleSpecifier.text;
179
+ specifiers.push(spec);
180
+ const bindings = stmt.importClause?.namedBindings;
181
+ if (FOUNDATION_ENTRY.test(spec) && stmt.importClause && !stmt.importClause.isTypeOnly) {
182
+ // Fail closed: a source the scan does not know about would otherwise read CLEAN.
183
+ if (stmt.importClause.name) defeat(stmt.importClause, DEFEATER.UNKNOWN_SOURCE);
184
+ if (bindings && ts.isNamedImports(bindings)) {
185
+ for (const el of bindings.elements) {
186
+ const imported = (el.propertyName ?? el.name).text;
187
+ if (el.isTypeOnly || SDK_FACTORIES[imported] || imported === AFTER_ASSEMBLY || imported === REQUEST_CONTEXT
188
+ || NON_CLIENT_EXPORTS.has(imported)) continue;
189
+ defeat(el, DEFEATER.UNKNOWN_SOURCE);
190
+ }
191
+ }
192
+ }
193
+ if (!bindings || !acceptsFactories(spec)) continue;
194
+ if (ts.isNamespaceImport(bindings)) namespaceLocals.add(bindings.name.text);
195
+ else {
196
+ for (const el of bindings.elements) {
197
+ const imported = (el.propertyName ?? el.name).text;
198
+ if (SDK_FACTORIES[imported]) factoryLocals.set(el.name.text, SDK_FACTORIES[imported]);
199
+ if (imported === AFTER_ASSEMBLY) afterAssemblyLocals.add(el.name.text);
200
+ if (imported === REQUEST_CONTEXT) requestContextLocals.add(el.name.text);
201
+ }
202
+ }
203
+ } else if (ts.isExportDeclaration(stmt) && stmt.moduleSpecifier && ts.isStringLiteral(stmt.moduleSpecifier)) {
204
+ specifiers.push(stmt.moduleSpecifier.text);
205
+ if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
206
+ for (const el of stmt.exportClause.elements) {
207
+ // Consumers import the alias, which the name-based tagging cannot recognise.
208
+ if (el.propertyName && isViewerSource(el.propertyName.text)) defeat(el, DEFEATER.FACTORY_ALIAS);
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ // --- provenance of an expression ----------------------------------------
215
+ const unwrap = (node) => {
216
+ let n = node;
217
+ let anyCast = false;
218
+ while (
219
+ ts.isParenthesizedExpression(n) || ts.isAwaitExpression(n) || ts.isNonNullExpression(n) ||
220
+ ts.isAsExpression(n) || ts.isTypeAssertionExpression(n) || ts.isSatisfiesExpression?.(n)
221
+ ) {
222
+ const t = n.type;
223
+ if (t && t.kind === ts.SyntaxKind.AnyKeyword) anyCast = true;
224
+ n = n.expression;
225
+ }
226
+ return { node: n, anyCast };
227
+ };
228
+
229
+ const namespaceMember = (expr) => {
230
+ if (ts.isPropertyAccessExpression(expr)) {
231
+ return ts.isIdentifier(expr.expression) && namespaceLocals.has(expr.expression.text)
232
+ ? expr.name.text : null;
233
+ }
234
+ if (ts.isElementAccessExpression(expr) && isLiteralText(expr.argumentExpression)) {
235
+ return ts.isIdentifier(expr.expression) && namespaceLocals.has(expr.expression.text)
236
+ ? expr.argumentExpression.text : null;
237
+ }
238
+ return null;
239
+ };
240
+
241
+ const factoryProvenance = (call, scope) => {
242
+ const callee = call.expression;
243
+ if (ts.isIdentifier(callee)) {
244
+ return !scope.lookup(callee.text) ? factoryLocals.get(callee.text) ?? null : null;
245
+ }
246
+ const member = namespaceMember(callee);
247
+ return member ? SDK_FACTORIES[member] ?? null : null;
248
+ };
249
+
250
+ const isAfterAssemblyCallee = (callee, scope) => {
251
+ if (ts.isIdentifier(callee)) return !scope.lookup(callee.text) && afterAssemblyLocals.has(callee.text);
252
+ return namespaceMember(callee) === AFTER_ASSEMBLY;
253
+ };
254
+
255
+ const isDynamicNamespaceAccess = (expr) =>
256
+ ts.isElementAccessExpression(expr) && ts.isIdentifier(expr.expression)
257
+ && namespaceLocals.has(expr.expression.text) && !isLiteralText(expr.argumentExpression);
258
+
259
+ const markUnusedNamespaceFactory = (node) => {
260
+ const parent = node.parent;
261
+ if (parent && ts.isCallExpression(parent) && parent.expression === node) return;
262
+ const name = namespaceMember(node);
263
+ if (name == null) {
264
+ if (isDynamicNamespaceAccess(node)) defeat(node, DEFEATER.DYNAMIC_ACCESS);
265
+ return;
266
+ }
267
+ if (isViewerSource(name)) defeat(node, DEFEATER.FACTORY_VALUE);
268
+ };
269
+
270
+ const isRequestContextCall = (node, scope) => {
271
+ if (!ts.isCallExpression(node)) return false;
272
+ const callee = node.expression;
273
+ if (ts.isIdentifier(callee)) return !scope.lookup(callee.text) && requestContextLocals.has(callee.text);
274
+ return namespaceMember(callee) === REQUEST_CONTEXT;
275
+ };
276
+
277
+ // The literal name `ctx`, a variable bound to getRequestContext(), or the call itself.
278
+ const isContextReceiver = (expr, scope) => {
279
+ if (ts.isIdentifier(expr)) return expr.text === 'ctx' || scope.lookup(expr.text)?.isCtx === true;
280
+ return isRequestContextCall(unwrap(expr).node, scope);
281
+ };
282
+
283
+ const ctxProvenance = (node, scope) =>
284
+ ts.isPropertyAccessExpression(node) && isContextReceiver(node.expression, scope)
285
+ ? CTX_MEMBERS[node.name.text] ?? null
286
+ : null;
287
+
288
+ // The provenance an expression carries when it is itself a receiver source or
289
+ // a reference to a tracked variable.
290
+ const provenanceOf = (node, scope) => {
291
+ if (ts.isCallExpression(node)) return factoryProvenance(node, scope);
292
+ if (ts.isIdentifier(node)) return scope.lookup(node.text)?.provenance ?? null;
293
+ return ctxProvenance(node, scope);
294
+ };
295
+
296
+ // --- usage of a tagged receiver -----------------------------------------
297
+ // Climbs out of wrappers that do not change what the value is, then reads how
298
+ // the value is used. Only viewer provenance records defeaters.
299
+ const classifyUse = (node, provenance, scope) => {
300
+ const viewer = provenance === PROVENANCE_VIEWER;
301
+ let cur = node;
302
+ let anyCast = false;
303
+ let awaited = false;
304
+ for (;;) {
305
+ const p = cur.parent;
306
+ if (p && (ts.isParenthesizedExpression(p) || ts.isNonNullExpression(p) || ts.isAwaitExpression(p) ||
307
+ ts.isAsExpression(p) || ts.isTypeAssertionExpression(p) || ts.isSatisfiesExpression?.(p))) {
308
+ if (ts.isAwaitExpression(p)) awaited = true;
309
+ if (p.type && p.type.kind === ts.SyntaxKind.AnyKeyword) anyCast = true;
310
+ cur = p;
311
+ } else break;
312
+ }
313
+ if (anyCast && viewer) defeat(node, DEFEATER.ANY_TYPED);
314
+ const parent = cur.parent;
315
+ if (!parent) return;
316
+
317
+ const recordCall = (method, at) => calls.push({ line: lineOf(sf, at), method, provenance });
318
+
319
+ if (ts.isPropertyAccessExpression(parent) && parent.expression === cur) {
320
+ const call = parent.parent;
321
+ const called = call && ts.isCallExpression(call) && call.expression === parent;
322
+ if (!called) {
323
+ if (viewer) defeat(parent, DEFEATER.METHOD_REFERENCE);
324
+ return;
325
+ }
326
+ const name = parent.name.text;
327
+ // `getAssembly().then(...)` is the client arriving later, not a method of it.
328
+ if (ts.isCallExpression(node) && !awaited && PROMISE_CHAIN_METHODS.has(name)) {
329
+ if (viewer) defeat(parent, DEFEATER.PROMISE_CHAIN);
330
+ return;
331
+ }
332
+ recordCall(name, parent.name);
333
+ return;
334
+ }
335
+ if (ts.isElementAccessExpression(parent) && parent.expression === cur) {
336
+ const arg = parent.argumentExpression;
337
+ const call = parent.parent;
338
+ if (isLiteralText(arg) && call && ts.isCallExpression(call) && call.expression === parent) {
339
+ recordCall(arg.text, arg);
340
+ } else if (viewer) defeat(parent, DEFEATER.DYNAMIC_ACCESS);
341
+ return;
342
+ }
343
+ if (ts.isVariableDeclaration(parent) && parent.initializer === cur) {
344
+ if (ts.isIdentifier(parent.name)) {
345
+ if (viewer && parent.type?.kind === ts.SyntaxKind.AnyKeyword) defeat(parent, DEFEATER.ANY_TYPED);
346
+ // The alias is bound by declareDecl; its own uses are classified there.
347
+ return;
348
+ }
349
+ if (viewer) defeat(parent, DEFEATER.DESTRUCTURED);
350
+ return;
351
+ }
352
+ if (!viewer) return;
353
+ if (ts.isSpreadElement(parent) || ts.isSpreadAssignment(parent)) return defeat(parent, DEFEATER.SPREAD);
354
+ if (ts.isExpressionStatement(parent)) return; // `await getAssembly();` for its side effect
355
+ if (ts.isPrefixUnaryExpression(parent) && parent.operator === ts.SyntaxKind.ExclamationToken) return;
356
+ if (ts.isIfStatement(parent) && parent.expression === cur) return;
357
+ if (ts.isBinaryExpression(parent)) {
358
+ const op = parent.operatorToken.kind;
359
+ if (op === ts.SyntaxKind.InstanceOfKeyword && parent.left === cur) return;
360
+ if (op === ts.SyntaxKind.EqualsEqualsToken || op === ts.SyntaxKind.EqualsEqualsEqualsToken
361
+ || op === ts.SyntaxKind.ExclamationEqualsToken || op === ts.SyntaxKind.ExclamationEqualsEqualsToken) return;
362
+ if (op === ts.SyntaxKind.EqualsToken && parent.right === cur) return defeat(parent, DEFEATER.REASSIGNED);
363
+ }
364
+ // getAssemblyForRequest returns a Response on failure; the documented
365
+ // `if (a instanceof NextResponse) return a;` hands that Response back, not
366
+ // the client, so it is not an escape.
367
+ if (ts.isReturnStatement(parent) && ts.isIdentifier(node) && returnsNarrowedResponse(parent, node.text)) return;
368
+ defeat(cur, DEFEATER.ESCAPES);
369
+ };
370
+
371
+ const returnsNarrowedResponse = (ret, name) => {
372
+ for (let n = ret.parent; n; n = n.parent) {
373
+ if (ts.isIfStatement(n)) {
374
+ const c = n.expression;
375
+ return ts.isBinaryExpression(c) && c.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword
376
+ && ts.isIdentifier(c.left) && c.left.text === name;
377
+ }
378
+ if (ts.isFunctionLike(n)) return false;
379
+ }
380
+ return false;
381
+ };
382
+
383
+ // The context call itself: bound to a variable (declareDecl follows it), read through
384
+ // a member, or dropped. Any other use hands the context somewhere the scan cannot see.
385
+ const checkContextCall = (node) => {
386
+ let cur = node;
387
+ let awaited = false;
388
+ for (;;) {
389
+ const p = cur.parent;
390
+ if (p && (ts.isParenthesizedExpression(p) || ts.isNonNullExpression(p) || ts.isAwaitExpression(p) ||
391
+ ts.isAsExpression(p) || ts.isTypeAssertionExpression(p) || ts.isSatisfiesExpression?.(p))) {
392
+ if (ts.isAwaitExpression(p)) awaited = true;
393
+ cur = p;
394
+ } else break;
395
+ }
396
+ const parent = cur.parent;
397
+ if (!parent) return;
398
+ if (ts.isVariableDeclaration(parent) && parent.initializer === cur) return;
399
+ if (ts.isPropertyAccessExpression(parent) && parent.expression === cur) {
400
+ // `getRequestContext().then(...)` is the context arriving later, not a member of it.
401
+ if (!awaited) defeat(parent, DEFEATER.PROMISE_CHAIN);
402
+ return;
403
+ }
404
+ if (ts.isExpressionStatement(parent)) return;
405
+ defeat(cur, DEFEATER.ESCAPES);
406
+ };
407
+
408
+ // --- bindings ------------------------------------------------------------
409
+ const declareNames = (name, scope, provenance) => {
410
+ if (ts.isIdentifier(name)) scope.bindings.set(name.text, { provenance });
411
+ else for (const el of name.elements) if (!ts.isOmittedExpression(el)) declareNames(el.name, scope, null);
412
+ };
413
+
414
+ const declareDecl = (decl, scope) => {
415
+ if (declared.has(decl)) return;
416
+ declared.add(decl);
417
+ let provenance = null;
418
+ if (decl.initializer && ts.isIdentifier(decl.name)) {
419
+ const init = unwrap(decl.initializer).node;
420
+ if (isRequestContextCall(init, scope)) {
421
+ scope.bindings.set(decl.name.text, { provenance: null, isCtx: true });
422
+ return;
423
+ }
424
+ provenance = provenanceOf(init, scope);
425
+ } else if (decl.initializer) {
426
+ // `const { assembly } = ctx` destructures a member out of the request context.
427
+ const init = unwrap(decl.initializer).node;
428
+ if (ts.isObjectBindingPattern(decl.name) && isContextReceiver(init, scope)) {
429
+ for (const el of decl.name.elements) {
430
+ // A rest or nested pattern takes members the scan cannot follow.
431
+ if (el.dotDotDotToken) defeat(el, DEFEATER.SPREAD);
432
+ else if (!ts.isIdentifier(el.name) || (el.propertyName && !ts.isIdentifier(el.propertyName))) {
433
+ defeat(el, DEFEATER.DESTRUCTURED);
434
+ }
435
+ const member = ts.isIdentifier(el.name) ? (el.propertyName ?? el.name).text : null;
436
+ if (member && CTX_MEMBERS[member] && ts.isIdentifier(el.name)) {
437
+ scope.bindings.set(el.name.text, { provenance: CTX_MEMBERS[member] });
438
+ } else if (ts.isIdentifier(el.name)) scope.bindings.set(el.name.text, { provenance: null });
439
+ }
440
+ return;
441
+ }
442
+ }
443
+ if (provenance === PROVENANCE_VIEWER && ts.isVariableStatement(decl.parent?.parent)
444
+ && hasExportModifier(decl.parent.parent)) {
445
+ defeat(decl, DEFEATER.ESCAPES);
446
+ }
447
+ declareNames(decl.name, scope, provenance);
448
+ };
449
+
450
+ const hasExportModifier = (stmt) => !!stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
451
+
452
+ // Hoisted: a function declared above `const assembly = await getAssembly()`
453
+ // still sees the binding when it runs.
454
+ const declareBlock = (statements, scope) => {
455
+ for (const stmt of statements) {
456
+ if (ts.isVariableStatement(stmt)) for (const d of stmt.declarationList.declarations) declareDecl(d, scope);
457
+ }
458
+ };
459
+
460
+ const isReferenceIdentifier = (node) => {
461
+ const p = node.parent;
462
+ if (!p) return false;
463
+ if (ts.isPropertyAccessExpression(p) && p.name === node) return false;
464
+ if ((ts.isPropertyAssignment(p) || ts.isPropertyDeclaration(p) || ts.isMethodDeclaration(p) ||
465
+ ts.isBindingElement(p) || ts.isParameter(p) || ts.isVariableDeclaration(p) ||
466
+ ts.isFunctionDeclaration(p) || ts.isClassDeclaration(p) || ts.isPropertySignature(p) ||
467
+ ts.isImportSpecifier(p) || ts.isImportClause(p) || ts.isNamespaceImport(p) ||
468
+ ts.isExportSpecifier(p) || ts.isLabeledStatement(p)) && p.name === node) return false;
469
+ if (ts.isBindingElement(p) && p.propertyName === node) return false;
470
+ if (ts.isTypeNode(p) || ts.isQualifiedName(p) || ts.isTypeQueryNode?.(p)) return false;
471
+ return true;
472
+ };
473
+
474
+ // Backstop for every way of obtaining a client the tagging above does not follow (a helper
475
+ // returning it, a destructured callback parameter, a binding exported from another file):
476
+ // an SDK-shaped method on a receiver with no recorded provenance is not provably safe.
477
+ const rootIdentifier = (expr) => {
478
+ let e = unwrap(expr).node;
479
+ while (ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e)) e = unwrap(e.expression).node;
480
+ return ts.isIdentifier(e) ? e.text : null;
481
+ };
482
+
483
+ const checkUntrackedSdkCall = (node, scope) => {
484
+ const callee = node.expression;
485
+ let name = null;
486
+ if (ts.isPropertyAccessExpression(callee)) name = callee.name.text;
487
+ else if (ts.isElementAccessExpression(callee) && isLiteralText(callee.argumentExpression)) name = callee.argumentExpression.text;
488
+ if (name == null || NON_SDK_METHODS.has(name)) return;
489
+ if (!SDK_METHOD_SHAPE.test(name) && !SDK_METHOD_EXTRAS.has(name)) return;
490
+ const receiver = unwrap(callee.expression).node;
491
+ if (provenanceOf(receiver, scope)) return;
492
+ const root = rootIdentifier(receiver);
493
+ if (root && !scope.lookup(root) && NON_SDK_RECEIVERS.has(root)) return;
494
+ defeat(callee, DEFEATER.UNTRACKED_SDK_METHOD);
495
+ };
496
+
497
+ // --- walk ----------------------------------------------------------------
498
+ const visit = (node, scope) => {
499
+ let inner = scope;
500
+ if (ts.isSourceFile(node) || ts.isBlock(node) || ts.isModuleBlock(node) || ts.isCaseBlock(node)) {
501
+ inner = ts.isSourceFile(node) ? scope : new Scope(scope);
502
+ declareBlock(ts.isCaseBlock(node) ? node.clauses.flatMap((c) => [...c.statements]) : node.statements, inner);
503
+ } else if (ts.isFunctionLike(node) || ts.isForStatement(node) || ts.isForInStatement(node) ||
504
+ ts.isForOfStatement(node) || ts.isCatchClause(node)) {
505
+ inner = new Scope(scope);
506
+ if (ts.isFunctionLike(node)) {
507
+ for (const prm of node.parameters) declareNames(prm.name, inner, null);
508
+ // afterAssembly's callback receives the viewer client as its first argument.
509
+ if (callbackViewerParams.has(node) && node.parameters[0]) {
510
+ const first = node.parameters[0];
511
+ if (ts.isIdentifier(first.name)) inner.bindings.set(first.name.text, { provenance: PROVENANCE_VIEWER });
512
+ // A pattern takes the client apart into names the scan then cannot follow.
513
+ else defeat(first, DEFEATER.DESTRUCTURED);
514
+ }
515
+ }
516
+ }
517
+
518
+ if (ts.isVariableDeclaration(node)) declareDecl(node, inner);
519
+
520
+ // `export { sdk }` hands a tracked viewer client to importers without a reference the scan reads.
521
+ if (ts.isExportSpecifier(node) && !node.parent.parent.moduleSpecifier
522
+ && inner.lookup((node.propertyName ?? node.name).text)?.provenance === PROVENANCE_VIEWER) {
523
+ defeat(node, DEFEATER.ESCAPES);
524
+ }
525
+
526
+ if (ts.isCatchClause(node) && node.variableDeclaration) declareNames(node.variableDeclaration.name, inner, null);
527
+
528
+ if (ts.isCallExpression(node)) {
529
+ checkUntrackedSdkCall(node, inner);
530
+ const callee = node.expression;
531
+ if (callee.kind === ts.SyntaxKind.ImportKeyword) {
532
+ const arg = node.arguments[0];
533
+ if (isLiteralText(arg)) specifiers.push(arg.text);
534
+ // Bindings from the module namespace are not followed, so a re-exported
535
+ // getAssembly would otherwise vanish. Follow the file; mark unresolved.
536
+ if (!isLiteralText(arg) || SDK_SPECIFIER.test(arg.text) || LOCAL_SPECIFIER.test(arg.text)) {
537
+ defeat(node, DEFEATER.DYNAMIC_IMPORT);
538
+ }
539
+ } else if (ts.isIdentifier(callee) && callee.text === 'require' && !inner.lookup('require')) {
540
+ const arg = node.arguments[0];
541
+ if (isLiteralText(arg)) specifiers.push(arg.text);
542
+ if (!isLiteralText(arg) || SDK_SPECIFIER.test(arg.text) || LOCAL_SPECIFIER.test(arg.text)) {
543
+ defeat(node, DEFEATER.DYNAMIC_IMPORT);
544
+ }
545
+ } else if (isAfterAssemblyCallee(callee, inner)) {
546
+ const cb = node.arguments[0];
547
+ if (cb && (ts.isArrowFunction(cb) || ts.isFunctionExpression(cb))) callbackViewerParams.add(cb);
548
+ else defeat(node, DEFEATER.ESCAPES);
549
+ } else if (isRequestContextCall(node, inner)) {
550
+ checkContextCall(node);
551
+ } else {
552
+ const provenance = factoryProvenance(node, inner);
553
+ if (provenance) classifyUse(node, provenance, inner);
554
+ else if (isDynamicNamespaceAccess(callee)) defeat(node, DEFEATER.DYNAMIC_ACCESS);
555
+ }
556
+ } else if (ts.isPropertyAccessExpression(node)) {
557
+ const provenance = ctxProvenance(node, inner);
558
+ if (provenance) classifyUse(node, provenance, inner);
559
+ else markUnusedNamespaceFactory(node);
560
+ } else if (ts.isElementAccessExpression(node)) {
561
+ markUnusedNamespaceFactory(node);
562
+ } else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken
563
+ && ts.isIdentifier(node.left)) {
564
+ // Reassigning a tracked variable to anything else would hide the receiver.
565
+ if (inner.lookup(node.left.text)?.provenance === PROVENANCE_VIEWER) defeat(node, DEFEATER.REASSIGNED);
566
+ } else if (ts.isIdentifier(node) && isReferenceIdentifier(node)) {
567
+ const binding = inner.lookup(node.text);
568
+ if (binding?.provenance) classifyUse(node, binding.provenance, inner);
569
+ else if (binding?.isCtx) {
570
+ // Only `.member` reads are followed; handing the context on hides its client.
571
+ const p = node.parent;
572
+ if (!(ts.isPropertyAccessExpression(p) && p.expression === node)) defeat(node, DEFEATER.ESCAPES);
573
+ }
574
+ else if (!binding && (factoryLocals.get(node.text) === PROVENANCE_VIEWER || afterAssemblyLocals.has(node.text)
575
+ || requestContextLocals.has(node.text))) {
576
+ const p = node.parent;
577
+ if (!(ts.isCallExpression(p) && p.expression === node)) defeat(node, DEFEATER.FACTORY_VALUE);
578
+ } else if (!binding && namespaceLocals.has(node.text)) {
579
+ const p = node.parent;
580
+ // Member access is classified on the property/element node; any other
581
+ // use (destructure, alias, pass) drops the factories where we cannot
582
+ // follow them, so CLEAN would hide a viewer call.
583
+ if (!((ts.isPropertyAccessExpression(p) || ts.isElementAccessExpression(p)) && p.expression === node)) {
584
+ defeat(node, DEFEATER.FACTORY_VALUE);
585
+ }
586
+ }
587
+ }
588
+
589
+ ts.forEachChild(node, (child) => visit(child, inner));
590
+ };
591
+
592
+ visit(sf, new Scope(null));
593
+ return { calls, defeaters, specifiers };
594
+ }
595
+
596
+ // ---------------------------------------------------------------------------
597
+ // Entries
598
+ // ---------------------------------------------------------------------------
599
+
600
+ // An entry that stops at a top-level check turning away anyone without an internalUserId
601
+ // (`if (!x.internalUserId) return/throw/notFound()/redirect()`) is reported in `gatedEntries`.
602
+ // It is an annotation for whoever triages a finding, never a reason to drop one: the check
603
+ // is recognised by name, so a guard on an unrelated object, or a sibling server action in the
604
+ // same file, would otherwise turn a client-reachable call into a false CLEAN.
605
+ const GATE_EXITS = new Set(['notFound', 'redirect', 'permanentRedirect', 'forbidden', 'unauthorized']);
606
+ const HTTP_HANDLERS = new Set(PROXY_METHODS);
607
+
608
+ function hasMod(node, kind) {
609
+ return !!node.modifiers?.some((m) => m.kind === kind);
610
+ }
611
+
612
+ function entryFunctions(ts, sf) {
613
+ const out = [];
614
+ const byName = new Map();
615
+ for (const stmt of sf.statements) {
616
+ if (ts.isFunctionDeclaration(stmt) && stmt.name) byName.set(stmt.name.text, stmt);
617
+ if (ts.isVariableStatement(stmt)) {
618
+ for (const d of stmt.declarationList.declarations) {
619
+ if (ts.isIdentifier(d.name) && d.initializer && ts.isFunctionLike(d.initializer)) {
620
+ byName.set(d.name.text, d.initializer);
621
+ }
622
+ }
623
+ }
624
+ }
625
+ for (const stmt of sf.statements) {
626
+ if (ts.isFunctionDeclaration(stmt) && hasMod(stmt, ts.SyntaxKind.DefaultKeyword)) {
627
+ out.push(stmt);
628
+ continue;
629
+ }
630
+ if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
631
+ if (ts.isFunctionLike(stmt.expression)) out.push(stmt.expression);
632
+ else if (ts.isIdentifier(stmt.expression) && byName.has(stmt.expression.text)) {
633
+ out.push(byName.get(stmt.expression.text));
634
+ }
635
+ continue;
636
+ }
637
+ if (ts.isFunctionDeclaration(stmt) && stmt.name && HTTP_HANDLERS.has(stmt.name.text)
638
+ && hasMod(stmt, ts.SyntaxKind.ExportKeyword)) {
639
+ out.push(stmt);
640
+ continue;
641
+ }
642
+ if (ts.isVariableStatement(stmt) && hasMod(stmt, ts.SyntaxKind.ExportKeyword)) {
643
+ for (const d of stmt.declarationList.declarations) {
644
+ if (ts.isIdentifier(d.name) && HTTP_HANDLERS.has(d.name.text)
645
+ && d.initializer && ts.isFunctionLike(d.initializer)) {
646
+ out.push(d.initializer);
647
+ }
648
+ }
649
+ }
650
+ if (ts.isExportDeclaration(stmt) && !stmt.moduleSpecifier
651
+ && stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
652
+ for (const el of stmt.exportClause.elements) {
653
+ const exported = el.name.text;
654
+ const local = (el.propertyName ?? el.name).text;
655
+ if (HTTP_HANDLERS.has(exported) && byName.has(local)) out.push(byName.get(local));
656
+ }
657
+ }
658
+ }
659
+ return out;
660
+ }
661
+
662
+ function hasInternalUserGate(ts, file, text) {
663
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind(ts, file));
664
+ const exits = (stmt) => {
665
+ if (!stmt) return false;
666
+ if (ts.isReturnStatement(stmt) || ts.isThrowStatement(stmt)) return true;
667
+ if (ts.isBlock(stmt)) return stmt.statements.some(exits);
668
+ if (ts.isExpressionStatement(stmt)) {
669
+ const e = stmt.expression;
670
+ return ts.isCallExpression(e) && ts.isIdentifier(e.expression) && GATE_EXITS.has(e.expression.text);
671
+ }
672
+ return false;
673
+ };
674
+ const isNegatedInternalCheck = (expr) =>
675
+ ts.isPrefixUnaryExpression(expr) && expr.operator === ts.SyntaxKind.ExclamationToken
676
+ && ts.isPropertyAccessExpression(expr.operand) && expr.operand.name.text === 'internalUserId';
677
+ const isLit = (n) => n && (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n));
678
+ const isViewerFactoryCallee = (expr) => {
679
+ if (ts.isIdentifier(expr)) {
680
+ return SDK_FACTORIES[expr.text] === PROVENANCE_VIEWER || expr.text === AFTER_ASSEMBLY;
681
+ }
682
+ if (ts.isPropertyAccessExpression(expr)) {
683
+ return SDK_FACTORIES[expr.name.text] === PROVENANCE_VIEWER || expr.name.text === AFTER_ASSEMBLY;
684
+ }
685
+ if (ts.isElementAccessExpression(expr) && isLit(expr.argumentExpression)) {
686
+ const name = expr.argumentExpression.text;
687
+ return SDK_FACTORIES[name] === PROVENANCE_VIEWER || name === AFTER_ASSEMBLY;
688
+ }
689
+ return false;
690
+ };
691
+ const isContextCall = (n) => {
692
+ let e = n;
693
+ while (ts.isParenthesizedExpression(e) || ts.isAwaitExpression(e) || ts.isNonNullExpression(e) || ts.isAsExpression(e)) {
694
+ e = e.expression;
695
+ }
696
+ return ts.isCallExpression(e) && (
697
+ (ts.isIdentifier(e.expression) && e.expression.text === REQUEST_CONTEXT) ||
698
+ (ts.isPropertyAccessExpression(e.expression) && e.expression.name.text === REQUEST_CONTEXT)
699
+ );
700
+ };
701
+ // Names a function binds from getRequestContext(): the context itself, and the viewer
702
+ // client destructured out of it. By name only, as the rest of the gate check is.
703
+ const contextNamesIn = (body) => {
704
+ const contextNames = new Set();
705
+ const viewerNames = new Set();
706
+ const walk = (n) => {
707
+ if (ts.isVariableDeclaration(n) && n.initializer && isContextCall(n.initializer)) {
708
+ if (ts.isIdentifier(n.name)) contextNames.add(n.name.text);
709
+ else if (ts.isObjectBindingPattern(n.name)) {
710
+ for (const el of n.name.elements) {
711
+ const member = el.propertyName ?? el.name;
712
+ if (ts.isIdentifier(member) && ts.isIdentifier(el.name) && CTX_MEMBERS[member.text] === PROVENANCE_VIEWER) {
713
+ viewerNames.add(el.name.text);
714
+ }
715
+ }
716
+ }
717
+ }
718
+ ts.forEachChild(n, walk);
719
+ };
720
+ walk(body);
721
+ return { contextNames, viewerNames };
722
+ };
723
+ const statementHasViewerSdkUse = (stmt, { contextNames, viewerNames }) => {
724
+ let found = false;
725
+ const walk = (n) => {
726
+ if (found) return;
727
+ if (ts.isCallExpression(n) && isViewerFactoryCallee(n.expression)) found = true;
728
+ else if (
729
+ ts.isPropertyAccessExpression(n) && CTX_MEMBERS[n.name.text] === PROVENANCE_VIEWER
730
+ && ((ts.isIdentifier(n.expression) && (n.expression.text === 'ctx' || contextNames.has(n.expression.text)))
731
+ || isContextCall(n.expression))
732
+ ) found = true;
733
+ else if (ts.isIdentifier(n) && viewerNames.has(n.text)) found = true;
734
+ else ts.forEachChild(n, walk);
735
+ };
736
+ walk(stmt);
737
+ return found;
738
+ };
739
+ const isGate = (stmt) =>
740
+ ts.isIfStatement(stmt) && isNegatedInternalCheck(stmt.expression) && exits(stmt.thenStatement);
741
+ const functionHasGate = (entry) => {
742
+ const body = entry.body;
743
+ if (!body || !ts.isBlock(body)) return false;
744
+ const names = contextNamesIn(body);
745
+ for (const stmt of body.statements) {
746
+ if (isGate(stmt)) return true;
747
+ if (statementHasViewerSdkUse(stmt, names)) return false;
748
+ }
749
+ return false;
750
+ };
751
+ const entries = entryFunctions(ts, sf);
752
+ return entries.length > 0 && entries.every(functionHasGate);
753
+ }
754
+
755
+ const REASON_RESERVED = 'reserved-segment';
756
+ const REASON_WRAPPER = 'wrapper';
757
+ const REASON_PRIVATE = 'private-routes';
758
+
759
+ function classifyEntries(ts, root, files, read) {
760
+ const privateRoutes = readPrivateRoutes(root);
761
+ const fileSet = new Set(files);
762
+ const gateCache = new Map();
763
+ const gated = (file) => {
764
+ if (!gateCache.has(file)) gateCache.set(file, fileSet.has(file) && hasInternalUserGate(ts, file, read(file)));
765
+ return gateCache.get(file);
766
+ };
767
+ // A gate in a layout turns away everyone below it. Layouts do not run around
768
+ // route.ts handlers, so only pages inherit them.
769
+ const ancestorLayouts = (file) => {
770
+ const parts = file.split('/');
771
+ const out = [];
772
+ for (let i = parts.length - 1; i >= 1; i--) {
773
+ const layout = [...parts.slice(0, i), 'layout'].join('/');
774
+ const hit = files.find((f) => f !== file && f.startsWith(`${layout}.`) && LAYOUT_FILE.test(f));
775
+ if (hit) out.push(hit);
776
+ }
777
+ return out;
778
+ };
779
+
780
+ const entries = [];
781
+ const excluded = [];
782
+ const gatedEntries = [];
783
+ for (const file of files) {
784
+ let templates = null;
785
+ let wrapper = null;
786
+ if (ROUTE_FILE_PREDICATE.test(file)) {
787
+ templates = templatesForFile(file);
788
+ if (!templates) continue; // Parallel-route slots and intercepting routes are not served.
789
+ const { names } = analyseExports(ts, file, read(file));
790
+ // A wrapper protects its own handler only: a sibling handler without one is still served.
791
+ const wrappers = [...names.entries()]
792
+ .filter(([name]) => PROXY_METHODS.includes(name))
793
+ .map(([, init]) => (init ? wrapperName(ts, init) : null));
794
+ wrapper = wrappers.length > 0 && wrappers.every((w) => w && EXCLUDING_WRAPPERS.includes(w)) ? wrappers[0] : null;
795
+ } else {
796
+ const m = PAGE_FILE.exec(file);
797
+ if (!m || file.startsWith('app/api/') || file.startsWith('src/app/api/')) continue;
798
+ // An unservable layout (slot, intercepting route) can still be loaded by
799
+ // a client, so it stays in rather than risk a false CLEAN.
800
+ templates = templatesForDirs(m[1].split('/').filter(Boolean)) ?? [{ template: '' }];
801
+ }
802
+
803
+ const reason = templates.some((t) => isReservedTemplate(t.template))
804
+ ? REASON_RESERVED
805
+ : wrapper
806
+ ? `${REASON_WRAPPER}:${wrapper}`
807
+ : templates.some((t) => privateRoutes.some((re) => re.test(t.template)))
808
+ ? REASON_PRIVATE
809
+ : null;
810
+ if (reason) excluded.push({ file, reason });
811
+ else {
812
+ entries.push(file);
813
+ if ((ROUTE_FILE_PREDICATE.test(file) ? [file] : [file, ...ancestorLayouts(file)]).some(gated)) gatedEntries.push(file);
814
+ }
815
+ }
816
+ entries.sort(byteCompare);
817
+ gatedEntries.sort(byteCompare);
818
+ excluded.sort((a, b) => byteCompare(a.file, b.file));
819
+ return { entries, excluded, gatedEntries };
820
+ }
821
+
822
+ // ---------------------------------------------------------------------------
823
+ // Analysis and projection
824
+ // ---------------------------------------------------------------------------
825
+
826
+ // Facts and the import graph for one app, computed lazily and memoised, so a
827
+ // projection pays only for the files it reaches.
828
+ export function createAnalysis(root) {
829
+ const ts = loadTypeScript();
830
+ const files = listFiles(root).filter((f) => SOURCE_FILE.test(f) && !DECLARATION_FILE.test(f)).sort(byteCompare);
831
+ const fileSet = new Set(files);
832
+ const texts = new Map();
833
+ const facts = new Map();
834
+
835
+ const read = (file) => {
836
+ if (!texts.has(file)) texts.set(file, readFileSync(path.join(root, file), 'utf8'));
837
+ return texts.get(file);
838
+ };
839
+
840
+ const resolveBase = (base) => {
841
+ if (fileSet.has(base)) return base;
842
+ for (const ext of RESOLVE_EXTENSIONS) if (fileSet.has(base + ext)) return base + ext;
843
+ for (const ext of RESOLVE_EXTENSIONS) if (fileSet.has(`${base}/index${ext}`)) return `${base}/index${ext}`;
844
+ return null;
845
+ };
846
+
847
+ // `@/` is tsconfig's usual alias for the app root or `src/`; reading
848
+ // tsconfig paths is out of scope, so an alias that fits neither is unresolved.
849
+ const resolve = (from, spec) => {
850
+ if (spec.startsWith('.')) return resolveBase(path.posix.normalize(path.posix.join(path.posix.dirname(from), spec)));
851
+ const rest = spec.replace(/^(?:@|~)\//, '');
852
+ for (const prefix of ALIAS_ROOTS) {
853
+ const hit = resolveBase(path.posix.normalize(prefix + rest));
854
+ if (hit) return hit;
855
+ }
856
+ return null;
857
+ };
858
+
859
+ const factsFor = (file) => {
860
+ if (!facts.has(file)) {
861
+ const raw = collectFileFacts(ts, file, read(file));
862
+ const imports = [];
863
+ const defeaters = [...raw.defeaters];
864
+ for (const spec of raw.specifiers) {
865
+ if (!LOCAL_SPECIFIER.test(spec)) continue;
866
+ const target = resolve(file, spec);
867
+ if (target) imports.push(target);
868
+ // Only a missing source file is a gap; assets and styles are not code.
869
+ else if (!/\.[a-z0-9]+$/i.test(spec) || RESOLVE_EXTENSIONS.some((e) => spec.endsWith(e))) {
870
+ defeaters.push({ line: 1, reason: DEFEATER.UNRESOLVED_IMPORT });
871
+ }
872
+ }
873
+ facts.set(file, { calls: raw.calls, defeaters, imports: [...new Set(imports)] });
874
+ }
875
+ return facts.get(file);
876
+ };
877
+
878
+ return { root, files, ts, read, facts: factsFor, classifyEntries: () => classifyEntries(ts, root, files, read) };
879
+ }
880
+
881
+ // Maps every file an entry can load to the byte-smallest entry that loads it.
882
+ function reachFrom(analysis, entries) {
883
+ const reach = new Map();
884
+ for (const entry of entries) {
885
+ const queue = [entry];
886
+ while (queue.length) {
887
+ const file = queue.pop();
888
+ if (reach.has(file)) continue;
889
+ reach.set(file, entry);
890
+ queue.push(...analysis.facts(file).imports);
891
+ }
892
+ }
893
+ return reach;
894
+ }
895
+
896
+ export const sortFindings = (list) =>
897
+ list.sort((a, b) => byteCompare(a.file, b.file) || a.line - b.line || byteCompare(a.method ?? a.reason, b.method ?? b.reason));
898
+
899
+ // The client-reachable projection: viewer calls outside the allowlist, and the
900
+ // constructs that stop the scan proving there are none.
901
+ export function projectClientReachable(analysis, allowlist = CLIENT_SAFE_SDK_METHODS) {
902
+ const allowed = new Set(allowlist);
903
+ const { entries, excluded, gatedEntries } = analysis.classifyEntries();
904
+ const reach = reachFrom(analysis, entries);
905
+ const findings = [];
906
+ const unresolved = [];
907
+ for (const [file, entry] of reach) {
908
+ const { calls, defeaters } = analysis.facts(file);
909
+ for (const c of calls) {
910
+ if (c.provenance === PROVENANCE_VIEWER && !allowed.has(c.method)) {
911
+ findings.push({ file, line: c.line, method: c.method, provenance: c.provenance, entry });
912
+ }
913
+ }
914
+ for (const d of defeaters) unresolved.push({ file, line: d.line, reason: d.reason, entry });
915
+ }
916
+ sortFindings(findings);
917
+ sortFindings(unresolved);
918
+ const cls = findings.length ? CLASS_BREAKS : unresolved.length ? CLASS_UNRESOLVED : CLASS_CLEAN;
919
+ return { class: cls, findings, unresolved, excludedEntries: excluded, gatedEntries };
920
+ }
921
+
922
+ export function scanClientCalls(root, { allowlist = CLIENT_SAFE_SDK_METHODS, appId } = {}) {
923
+ const app = { app: appId ?? path.basename(path.resolve(root)), ...projectClientReachable(createAnalysis(root), allowlist) };
924
+ return { scannerVersion: SCANNER_VERSION, apps: [app] };
925
+ }
926
+
927
+ export const serializeClientCalls = (result) => JSON.stringify(result, null, 2) + '\n';
928
+
929
+ // Plug-in row for bin/scan.mjs. It has no `output`: the result is returned to
930
+ // the caller instead of being committed into the app.
931
+ export const clientCallScanner = {
932
+ name: 'client-calls',
933
+ run: (root) => ({ result: scanClientCalls(root), warnings: [] }),
934
+ };
@@ -36,9 +36,9 @@ const APP_JSON_PATH = '.assembly/app.json';
36
36
  const NEXT_CONFIG_FILES = ['next.config.js', 'next.config.mjs', 'next.config.cjs', 'next.config.ts', 'next.config.mts'];
37
37
  const SKIPPED_DIRS = new Set(['node_modules', '.git', '.next', '.vercel', '.turbo']);
38
38
 
39
- const byteCompare = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
39
+ export const byteCompare = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
40
40
 
41
- function loadTypeScript() {
41
+ export function loadTypeScript() {
42
42
  // Resolved from this file, so an installed package finds the app's own
43
43
  // typescript and this repo's tests find the devDependency.
44
44
  return createRequire(import.meta.url)('typescript');
@@ -60,7 +60,7 @@ function walk(root, dir = '') {
60
60
  }
61
61
 
62
62
  // What `git add -A` would stage, so the scan describes the commit it rides in.
63
- function listFiles(root) {
63
+ export function listFiles(root) {
64
64
  if (existsSync(path.join(root, '.git'))) {
65
65
  const res = spawnSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], {
66
66
  cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
@@ -122,7 +122,11 @@ const DYNAMIC = /^\[([^\].]+)\]$/;
122
122
  // proxy cannot serve (parallel-route slots, intercepting routes); an optional
123
123
  // catch-all yields two templates.
124
124
  export function templatesForFile(file) {
125
- const dirs = file.slice(API_PREFIX.length).split('/').slice(0, -1);
125
+ return templatesForDirs(file.slice(API_PREFIX.length).split('/').slice(0, -1));
126
+ }
127
+
128
+ // The segment walk, shared with scanners that derive page templates from `app/`.
129
+ export function templatesForDirs(dirs) {
126
130
  let templates = [{ segments: [], catchAll: false }];
127
131
  for (const seg of dirs) {
128
132
  if (SLOT.test(seg) || INTERCEPTING.test(seg)) return null;
@@ -169,7 +173,7 @@ function globToRegExp(glob) {
169
173
  return new RegExp(`^${re}$`);
170
174
  }
171
175
 
172
- function readPrivateRoutes(root) {
176
+ export function readPrivateRoutes(root) {
173
177
  try {
174
178
  const parsed = JSON.parse(readFileSync(path.join(root, APP_JSON_PATH), 'utf8'));
175
179
  const list = parsed?.privateRoutes;
@@ -187,7 +191,7 @@ const hasModifier = (ts, node, kind) => node.modifiers?.some((m) => m.kind === k
187
191
 
188
192
  // Returns the exported handler bindings and whether the export surface is
189
193
  // opaque (`export *`, CommonJS) so names cannot be read statically.
190
- function analyseExports(ts, file, text) {
194
+ export function analyseExports(ts, file, text) {
191
195
  const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, false, file.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
192
196
  const names = new Map(); // exported name -> initializer expression | null
193
197
  let opaque = false;
@@ -236,7 +240,7 @@ function analyseExports(ts, file, text) {
236
240
  // The callee identifier of a handler initializer, matched by name only: no
237
241
  // resolution, so `export const POST = cronRoute(...)` is recognised without
238
242
  // following the import.
239
- function wrapperName(ts, init) {
243
+ export function wrapperName(ts, init) {
240
244
  let node = init;
241
245
  while (node && (ts.isParenthesizedExpression(node) || ts.isAwaitExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression?.(node))) {
242
246
  node = node.expression;
package/bin/scan.mjs CHANGED
@@ -9,6 +9,7 @@
9
9
  import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
+ import { clientCallScanner } from './scan-client-calls.mjs';
12
13
  import { scanRoutes, serializeRoutes } from './scan-routes.mjs';
13
14
 
14
15
  const SCANNERS = [
@@ -22,6 +23,11 @@ const SCANNERS = [
22
23
  },
23
24
  ];
24
25
 
26
+ // Scanners whose result goes to the caller, not to a committed file. They run
27
+ // only when asked (`runScanners(root, ON_DEMAND_SCANNERS)`), so a generated app
28
+ // pays for them on no turn and gains no new file.
29
+ export const ON_DEMAND_SCANNERS = [clientCallScanner];
30
+
25
31
  function writeIfChanged(file, content) {
26
32
  try {
27
33
  if (readFileSync(file, 'utf8') === content) return;
@@ -35,22 +41,24 @@ function writeIfChanged(file, content) {
35
41
  renameSync(tmp, file);
36
42
  }
37
43
 
38
- export function runScanners(root = process.cwd()) {
44
+ export function runScanners(root = process.cwd(), scanners = SCANNERS) {
39
45
  // Every scanner runs before any file is written, so one throwing leaves all
40
46
  // previous outputs intact rather than a mix of old and new.
41
- const results = SCANNERS.map((scanner) => ({ scanner, ...scanner.run(root) }));
47
+ const results = scanners.map((scanner) => ({ scanner, ...scanner.run(root) }));
42
48
  const warnings = [];
43
- for (const { scanner, content, warnings: found } of results) {
44
- writeIfChanged(path.join(root, scanner.output), content);
49
+ const outputs = {};
50
+ for (const { scanner, content, result, warnings: found } of results) {
51
+ if (scanner.output) writeIfChanged(path.join(root, scanner.output), content);
52
+ else outputs[scanner.name] = result;
45
53
  warnings.push(...found.map((w) => ({ scanner: scanner.name, ...w })));
46
54
  }
47
- return { warnings };
55
+ return { warnings, outputs };
48
56
  }
49
57
 
50
58
  // argv[1] is not realpathed but import.meta.url is, so a symlinked path would skip the run.
51
59
  if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
52
60
  try {
53
- process.stdout.write(JSON.stringify(runScanners()) + '\n');
61
+ process.stdout.write(JSON.stringify({ warnings: runScanners().warnings }) + '\n');
54
62
  } catch (err) {
55
63
  console.error(`scan: ${err && err.stack ? err.stack : err}`);
56
64
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.54",
3
+ "version": "0.0.55",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [