@assembly-js/app-foundation 0.0.53 → 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 +7 -0
- package/CLAUDE.md +3 -1
- package/bin/client-safe-sdk-methods.mjs +11 -0
- package/bin/foundation-exports.mjs +59 -0
- package/bin/scan-client-calls.mjs +934 -0
- package/bin/scan-routes.mjs +11 -7
- package/bin/scan.mjs +14 -6
- package/dist/app-installs.d.ts +17 -0
- package/dist/app-installs.js +34 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/package.json +1 -1
- package/skills/assembly-native-resources/SKILL.md +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,13 @@ 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
|
+
|
|
20
|
+
## 0.0.54
|
|
21
|
+
- [new] Add `isAppInstalled` and `getInstalledAppIds`, helpers an app can adopt to check which optional modules are installed in its workspace.
|
|
22
|
+
- [guidance] The build agent is taught to check that a module is installed before relying on features that depend on it.
|
|
23
|
+
|
|
17
24
|
## 0.0.53
|
|
18
25
|
- [action] A page or server action that calls a helper exported from a client-component file errors for every visitor who opens it; the build now reports the call, and the helper moves to a plain module both sides import.
|
|
19
26
|
- [guidance] Tell the build agent that a helper shared between a server-rendered page and a client component belongs in a plain module, not the client component's file.
|
package/CLAUDE.md
CHANGED
|
@@ -167,7 +167,9 @@ Before adding a feature, check whether Assembly already covers it. If it does, *
|
|
|
167
167
|
|
|
168
168
|
**Always available (every workspace):** Clients, companies, and custom fields — see "The data model" below. Use them; do not rebuild them. Notification infrastructure (`listNotifications`, `createNotification`, `deleteNotification`, `markNotificationRead`, `markNotificationUnread`) is also always available.
|
|
169
169
|
|
|
170
|
-
**Optional modules (may or may not be installed in this workspace):** Messages, Files, Contracts, Forms, Billing, Knowledge Base, Tasks, and Notes. Before suggesting one or
|
|
170
|
+
**Optional modules (may or may not be installed in this workspace):** Messages, Files, Contracts, Forms, Billing, Knowledge Base, Tasks, and Notes. Before suggesting one or building on one, confirm it is installed: gate the feature with `isAppInstalled(assembly, <appId>)` from `@assembly-js/app-foundation/server` and render an honest "<app> isn't installed in this workspace" state when it is false; the plan's module list is the build-time source of truth. If it is installed, use it through the SDK; do not rebuild it. If it is not installed, do not suggest it, do not plan the app around it, and do not ask the user to install it.
|
|
171
|
+
|
|
172
|
+
**Never ask the user to test a client-facing write (a form request, message, invoice, notification) on a real client.** Suggest a throwaway test client instead, and say before the first test that a form request can't be undone from the app — it can only be removed in the dashboard.
|
|
171
173
|
|
|
172
174
|
- **Messages** — Direct messaging between staff and clients. SDK groups: Messages, MessageChannels.
|
|
173
175
|
- **Files** — Secure file/folder sharing. *For app-private uploads (attachments, exports) use the storage helpers from `@assembly-js/app-foundation/server` (see the `assembly-storage` skill) instead — Files is only for files the user wants in the portal Files tab.* SDK groups: Files, FileChannels.
|
|
@@ -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
|
+
};
|
package/bin/scan-routes.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
47
|
+
const results = scanners.map((scanner) => ({ scanner, ...scanner.run(root) }));
|
|
42
48
|
const warnings = [];
|
|
43
|
-
|
|
44
|
-
|
|
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);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AssemblyAPI } from '@assembly-js/node-sdk';
|
|
2
|
+
/**
|
|
3
|
+
* @deprecated Built-in modules are being replaced by generated apps, so this
|
|
4
|
+
* map will be removed. Prefer an appId you resolved from the workspace; use
|
|
5
|
+
* these only to gate a feature on a module that still ships built in.
|
|
6
|
+
*/
|
|
7
|
+
export declare const LEGACY_APP_IDS: {
|
|
8
|
+
readonly messages: "38323633-6465-6132-3730-383065323230";
|
|
9
|
+
readonly billing: "64346438-6461-6431-6565-366361383434";
|
|
10
|
+
readonly files: "33343966-3136-6166-6561-343739303432";
|
|
11
|
+
readonly contracts: "34633033-3365-6561-6332-386434316430";
|
|
12
|
+
readonly forms: "66666663-3861-6633-3830-323337613065";
|
|
13
|
+
readonly helpdesk: "30643961-3563-3361-3035-396231663435";
|
|
14
|
+
readonly tasks: "0e1dff29-1ce3-40ac-b58d-264097f6b319";
|
|
15
|
+
};
|
|
16
|
+
export declare function getInstalledAppIds(assembly: AssemblyAPI): Promise<Set<string>>;
|
|
17
|
+
export declare function isAppInstalled(assembly: AssemblyAPI, appId: string): Promise<boolean>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Every node-sdk call for install lookups lives in getInstalledAppIds, so the
|
|
2
|
+
// node-sdk namespace migration is a one-line change here and never reaches
|
|
3
|
+
// generated apps.
|
|
4
|
+
/**
|
|
5
|
+
* @deprecated Built-in modules are being replaced by generated apps, so this
|
|
6
|
+
* map will be removed. Prefer an appId you resolved from the workspace; use
|
|
7
|
+
* these only to gate a feature on a module that still ships built in.
|
|
8
|
+
*/
|
|
9
|
+
export const LEGACY_APP_IDS = {
|
|
10
|
+
messages: '38323633-6465-6132-3730-383065323230',
|
|
11
|
+
billing: '64346438-6461-6431-6565-366361383434',
|
|
12
|
+
files: '33343966-3136-6166-6561-343739303432',
|
|
13
|
+
contracts: '34633033-3365-6561-6332-386434316430',
|
|
14
|
+
forms: '66666663-3861-6633-3830-323337613065',
|
|
15
|
+
helpdesk: '30643961-3563-3361-3035-396231663435',
|
|
16
|
+
tasks: '0e1dff29-1ce3-40ac-b58d-264097f6b319',
|
|
17
|
+
};
|
|
18
|
+
// A disabled install is dropped: it is present in the list but its surfaces and
|
|
19
|
+
// API behave as if the app were not there.
|
|
20
|
+
export async function getInstalledAppIds(assembly) {
|
|
21
|
+
try {
|
|
22
|
+
const installs = await assembly.listAppInstalls({});
|
|
23
|
+
return new Set(installs.filter((i) => !i.disabled && i.appId).map((i) => i.appId));
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
// Fails closed so a lookup error renders the "not installed" state instead
|
|
27
|
+
// of throwing into the page.
|
|
28
|
+
console.error('getInstalledAppIds: listAppInstalls failed', err);
|
|
29
|
+
return new Set();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function isAppInstalled(assembly, appId) {
|
|
33
|
+
return (await getInstalledAppIds(assembly)).has(appId);
|
|
34
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from './assembly.js';
|
|
|
3
3
|
export * from './action-route.js';
|
|
4
4
|
export * from './auth.js';
|
|
5
5
|
export * from './client-access.js';
|
|
6
|
+
export * from './app-installs.js';
|
|
6
7
|
export * from './mint-bearer.js';
|
|
7
8
|
export * from './verify-bearer.js';
|
|
8
9
|
export * from './nango.js';
|
package/dist/server.js
CHANGED
|
@@ -7,6 +7,7 @@ export * from './assembly.js';
|
|
|
7
7
|
export * from './action-route.js';
|
|
8
8
|
export * from './auth.js';
|
|
9
9
|
export * from './client-access.js';
|
|
10
|
+
export * from './app-installs.js';
|
|
10
11
|
export * from './mint-bearer.js';
|
|
11
12
|
export * from './verify-bearer.js';
|
|
12
13
|
export * from './nango.js';
|
package/package.json
CHANGED
|
@@ -9,6 +9,10 @@ description: Use any time the app references a native Assembly message channel,
|
|
|
9
9
|
|
|
10
10
|
Discover instead. The pattern is always the same two steps: **list, then match on a stable attribute** the workspace owner controls (a title, a name), or resolve the resource from the current request's identity. Never a literal.
|
|
11
11
|
|
|
12
|
+
## Check the app is installed first
|
|
13
|
+
|
|
14
|
+
Gate any feature that depends on another app with `isAppInstalled(assembly, <appId>)` from `@assembly-js/app-foundation/server`, and show an honest "<app> isn't installed in this workspace" state when it is false. It works the same for core, marketplace and custom apps.
|
|
15
|
+
|
|
12
16
|
## Discovering a form
|
|
13
17
|
|
|
14
18
|
A form's display name is on `fields`, not on the form itself. Walk every page — matching by name against only the first page reports the form as missing once the workspace has more than a page of forms:
|
|
@@ -31,6 +35,8 @@ await assembly.requestFormResponse({
|
|
|
31
35
|
});
|
|
32
36
|
```
|
|
33
37
|
|
|
38
|
+
**Testing a form request:** never ask the user to send one to a real client. Suggest a throwaway test client, and say before the first test that a request can't be undone from the app — it can only be removed in the dashboard.
|
|
39
|
+
|
|
34
40
|
There is **no** method for a custom app to submit answers to a native form — you can request a response from a client and read `listFormResponses`, nothing more. Build your own form (see `assembly-forms`) if the app needs to collect the answers itself.
|
|
35
41
|
|
|
36
42
|
## Discovering a message channel
|