@assembly-js/app-foundation 0.2.4 → 0.2.5

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
@@ -15,7 +15,12 @@ from every bullet except `[guidance]`, agent work from the check scripts those `
15
15
  bullets ship. Write `[fix]`, `[behavior]` and `[action]` bullets for the app's owner.
16
16
 
17
17
  A patch release never changes what an app's build requires; a minor release may add or change a
18
- `bin/check-*.mjs` script. CI fails a PR that changes a check script without a minor bump over main.
18
+ `bin/check-*.mjs` script. Sections are written by the release workflow from the files in `changes/`,
19
+ not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
20
+ `bump: minor` change file.
21
+
22
+ ## 0.2.5
23
+ - [new] The client-call scan no longer reports reading the session's token details, a client's allowed fetch routes, or a harmless local namespace import as breaking; nothing in the app changes.
19
24
 
20
25
  ## 0.2.4
21
26
  - [new] Add `@assembly-js/app-foundation/codegen`, which turns another app's API description into a typed client the app can call by verb and path, and `appProxyOptions` in `/server`, which that client uses to reach the other app through the platform. Nothing in the app changes until a client is generated for it.
@@ -1,28 +1,38 @@
1
- // The routes a client bearer may keep calling once clients stop acting as admin,
2
- // as SDK method names (the SDK is flat: `retrieveClient`, not `clients.retrieve`).
1
+ // The routes a client bearer may keep calling once clients stop acting as admin.
3
2
  // Mirrors every route the platform marks with ClientAccessOpen, ClientAccessSelf or
4
3
  // ClientAccessGuarded under backend/platform-api; scan-client-calls.test.mjs fails when
5
- // the marker count drifts from this list.
6
- //
7
- // GET /v1/me -> retrieveMe
8
- // GET /v1/clients/{id} -> retrieveClient (the client's own record only)
9
- // GET /v1/notifications -> listNotifications
10
- // POST /v1/notifications/{id}/read -> markNotificationRead
11
- // POST /v1/notifications/{id}/unread -> markNotificationUnread
12
- // GET /v1/session/flags -> no SDK method yet, so nothing to list; add it
13
- // here when the SDK gains one.
4
+ // the marker count drifts from this table. The SDK method list, the count of routes with
5
+ // no SDK method and the `fetch` route check are all derived from the one table, so they
6
+ // cannot disagree. The SDK is flat: `retrieveClient`, not `clients.retrieve`.
14
7
  //
8
+ // A route with no SDK method yet (`sdkMethod: null`) is reachable only through `fetch`;
9
+ // give it a method here when the SDK gains one.
10
+ export const CLIENT_SAFE_ROUTES = Object.freeze([
11
+ { method: 'GET', path: '/v1/clients/{id}', sdkMethod: 'retrieveClient' }, // the client's own record only
12
+ { method: 'GET', path: '/v1/me', sdkMethod: 'retrieveMe' },
13
+ { method: 'GET', path: '/v1/notifications', sdkMethod: 'listNotifications' },
14
+ { method: 'POST', path: '/v1/notifications/{id}/read', sdkMethod: 'markNotificationRead' },
15
+ { method: 'POST', path: '/v1/notifications/{id}/unread', sdkMethod: 'markNotificationUnread' },
16
+ { method: 'GET', path: '/v1/session/flags', sdkMethod: null },
17
+ ].map(Object.freeze));
18
+
15
19
  // retrieveClient is allowlisted by name: whether the id is the viewer's own is a runtime
16
20
  // fact a static scan cannot see. The notification routes are open to clients, and the
17
21
  // platform pins a client caller to their own notifications. Creating, retrieving and
18
22
  // deleting a notification stay off the list: those routes are not open to clients.
19
- export const CLIENT_SAFE_SDK_METHODS = Object.freeze([
20
- 'retrieveClient',
21
- 'retrieveMe',
22
- 'listNotifications',
23
- 'markNotificationRead',
24
- 'markNotificationUnread',
25
- ]);
23
+ export const CLIENT_SAFE_SDK_METHODS = Object.freeze(
24
+ CLIENT_SAFE_ROUTES.flatMap((r) => (r.sdkMethod ? [r.sdkMethod] : [])),
25
+ );
26
+
27
+ // Marked client-safe routes that no SDK method reaches yet.
28
+ export const CLIENT_SAFE_ROUTES_WITHOUT_SDK_METHOD = CLIENT_SAFE_ROUTES.filter((r) => !r.sdkMethod).length;
29
+
30
+ // `{id}` stands for exactly one non-empty path segment.
31
+ // The id segment excludes `.` and `%` so `.`, `..` and `%2e` forms that URL normalisation resolves elsewhere fail closed.
32
+ const routePattern = (route) =>
33
+ new RegExp(`^${route.path.split('/').map((seg) => (seg === '{id}' ? '[A-Za-z0-9_-]+' : seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))).join('/')}$`);
34
+ const ROUTE_MATCHERS = CLIENT_SAFE_ROUTES.map((route) => ({ method: route.method, pattern: routePattern(route) }));
26
35
 
27
- // Marked client-safe routes that no SDK method reaches yet (the list above says which).
28
- export const CLIENT_SAFE_ROUTES_WITHOUT_SDK_METHOD = 1;
36
+ // Whether `fetch(path, { method })` hits a client-safe route; `path` has no query string.
37
+ export const isClientSafeRoute = (method, path) =>
38
+ ROUTE_MATCHERS.some((m) => m.method === method.toUpperCase() && m.pattern.test(path));
@@ -23,7 +23,7 @@
23
23
  import { readdirSync, readFileSync } from 'node:fs';
24
24
  import { createRequire } from 'node:module';
25
25
  import path from 'node:path';
26
- import { CLIENT_SAFE_SDK_METHODS } from './client-safe-sdk-methods.mjs';
26
+ import { CLIENT_SAFE_SDK_METHODS, isClientSafeRoute } from './client-safe-sdk-methods.mjs';
27
27
  import { NON_CLIENT_EXPORTS } from './foundation-exports.mjs';
28
28
  import {
29
29
  EXCLUDING_WRAPPERS,
@@ -94,6 +94,11 @@ export const SDK_METHOD_SHAPE = /^(?:(?:list|retrieve|create|update|delete|send|
94
94
  // `fetch` and `sendWebhook` are the flat client methods the codegen services do not declare, and
95
95
  // the verb pattern cannot match; `fetch` is how apps reach non-SDK endpoints such as the AI proxy.
96
96
  export const SDK_METHOD_EXTRAS = new Set(['fetch', 'sendWebhook']);
97
+ // SDK methods that only read the token already in hand, so a viewer calling them reaches no endpoint.
98
+ // Skipped at projection, not collection, so per-file facts stay reusable.
99
+ const NON_NETWORK_SDK_METHODS = new Set(['getTokenPayload']);
100
+ const FETCH_METHOD = 'fetch';
101
+ const DEFAULT_FETCH_HTTP_METHOD = 'GET';
97
102
  const SDK_SERVICES_DIR = '../codegen/api/services';
98
103
  const SDK_SERVICE_FILE = /Service\.d\.ts$/;
99
104
  const SDK_STATIC_METHOD = /static (\w+)\(/g;
@@ -178,7 +183,9 @@ function scriptKind(ts, file) {
178
183
  // `calls` are every method call on a tagged receiver, `defeaters` are the
179
184
  // constructs that stop the scan following a viewer receiver, `specifiers` the
180
185
  // module specifiers it imports.
181
- export function collectFileFacts(ts, file, text, sdkMethods = null) {
186
+ // `localExportsFactory(spec)` says whether a local module exports an SDK factory; without it every
187
+ // local namespace import is tracked.
188
+ export function collectFileFacts(ts, file, text, sdkMethods = null, localExportsFactory = null) {
182
189
  const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind(ts, file));
183
190
  const calls = [];
184
191
  const defeaters = [];
@@ -194,6 +201,26 @@ export function collectFileFacts(ts, file, text, sdkMethods = null) {
194
201
 
195
202
  const isLiteralText = (n) => n && (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n));
196
203
 
204
+ // The literal route and HTTP method of a `fetch(path, init?)` call, or null when either is not
205
+ // literal, so the projection fails closed. A missing method option is a GET.
206
+ const fetchRoute = (call) => {
207
+ const [pathArg, init] = call.arguments;
208
+ if (!isLiteralText(pathArg)) return null;
209
+ let method = DEFAULT_FETCH_HTTP_METHOD;
210
+ if (init) {
211
+ if (!ts.isObjectLiteralExpression(init)) return null;
212
+ for (const prop of init.properties) {
213
+ if (ts.isSpreadAssignment(prop) || (prop.name && ts.isComputedPropertyName(prop.name))) return null;
214
+ if (prop.name?.text !== 'method') continue;
215
+ if (!ts.isPropertyAssignment(prop) || !isLiteralText(prop.initializer)) return null;
216
+ method = prop.initializer.text;
217
+ }
218
+ }
219
+ // The SDK prepends the slash a path is missing, so `v1/me` requests `/v1/me`.
220
+ const routePath = pathArg.text.startsWith('/') ? pathArg.text : `/${pathArg.text}`;
221
+ return { path: routePath.replace(/[?#].*$/, ''), method };
222
+ };
223
+
197
224
  // --- imports ------------------------------------------------------------
198
225
  const acceptsFactories = (spec) => LOCAL_SPECIFIER.test(spec) || SDK_SPECIFIER.test(spec);
199
226
  for (const stmt of sf.statements) {
@@ -214,7 +241,12 @@ export function collectFileFacts(ts, file, text, sdkMethods = null) {
214
241
  }
215
242
  }
216
243
  if (!bindings || !acceptsFactories(spec)) continue;
217
- if (ts.isNamespaceImport(bindings)) namespaceLocals.add(bindings.name.text);
244
+ if (ts.isNamespaceImport(bindings)) {
245
+ // A namespace of a module with no factory (a drizzle schema) cannot hand out a client.
246
+ if (!localExportsFactory || !LOCAL_SPECIFIER.test(spec) || localExportsFactory(spec)) {
247
+ namespaceLocals.add(bindings.name.text);
248
+ }
249
+ }
218
250
  else {
219
251
  for (const el of bindings.elements) {
220
252
  const imported = (el.propertyName ?? el.name).text;
@@ -337,7 +369,12 @@ export function collectFileFacts(ts, file, text, sdkMethods = null) {
337
369
  const parent = cur.parent;
338
370
  if (!parent) return;
339
371
 
340
- const recordCall = (method, at) => calls.push({ line: lineOf(sf, at), method, provenance });
372
+ const recordCall = (method, at, call) => calls.push({
373
+ line: lineOf(sf, at),
374
+ method,
375
+ provenance,
376
+ ...(method === FETCH_METHOD ? { route: fetchRoute(call) } : {}),
377
+ });
341
378
 
342
379
  if (ts.isPropertyAccessExpression(parent) && parent.expression === cur) {
343
380
  const call = parent.parent;
@@ -352,14 +389,14 @@ export function collectFileFacts(ts, file, text, sdkMethods = null) {
352
389
  if (viewer) defeat(parent, DEFEATER.PROMISE_CHAIN);
353
390
  return;
354
391
  }
355
- recordCall(name, parent.name);
392
+ recordCall(name, parent.name, call);
356
393
  return;
357
394
  }
358
395
  if (ts.isElementAccessExpression(parent) && parent.expression === cur) {
359
396
  const arg = parent.argumentExpression;
360
397
  const call = parent.parent;
361
398
  if (isLiteralText(arg) && call && ts.isCallExpression(call) && call.expression === parent) {
362
- recordCall(arg.text, arg);
399
+ recordCall(arg.text, arg, call);
363
400
  } else if (viewer) defeat(parent, DEFEATER.DYNAMIC_ACCESS);
364
401
  return;
365
402
  }
@@ -880,9 +917,28 @@ export function createAnalysis(root) {
880
917
  return null;
881
918
  };
882
919
 
920
+ // Over-approximates (a mention plus any `export`): tracking too much only costs a defeater.
921
+ const FACTORY_NAME = new RegExp(`\\b(?:${[...Object.keys(SDK_FACTORIES), AFTER_ASSEMBLY, REQUEST_CONTEXT].join('|')})\\b`);
922
+ const STAR_REEXPORT = /export\s*(?:\*(?:\s*as\s+\w+)?|\{[^}]*\})\s*from\s*['"]([^'"]+)['"]/g;
923
+ const exportsFactory = (file, seen = new Set()) => {
924
+ if (seen.has(file)) return false;
925
+ seen.add(file);
926
+ const text = read(file);
927
+ if (/\bexport\b/.test(text) && FACTORY_NAME.test(text)) return true;
928
+ return [...text.matchAll(STAR_REEXPORT)].some(([, spec]) => {
929
+ if (SDK_SPECIFIER.test(spec)) return true;
930
+ const target = LOCAL_SPECIFIER.test(spec) ? resolve(file, spec) : null;
931
+ return target ? exportsFactory(target, seen) : false;
932
+ });
933
+ };
934
+
883
935
  const factsFor = (file) => {
884
936
  if (!facts.has(file)) {
885
- const raw = collectFileFacts(ts, file, read(file), sdkMethods);
937
+ const localExportsFactory = (spec) => {
938
+ const target = resolve(file, spec);
939
+ return target ? exportsFactory(target) : true;
940
+ };
941
+ const raw = collectFileFacts(ts, file, read(file), sdkMethods, localExportsFactory);
886
942
  const imports = [];
887
943
  const defeaters = [...raw.defeaters];
888
944
  for (const spec of raw.specifiers) {
@@ -938,7 +994,9 @@ export function projectClientReachable(analysis, allowlist = CLIENT_SAFE_SDK_MET
938
994
  const gated = [...reaching].every((e) => gatedSet.has(e));
939
995
  const { calls, defeaters } = analysis.facts(file);
940
996
  for (const c of calls) {
941
- if (c.provenance === PROVENANCE_VIEWER && !allowed.has(c.method)) {
997
+ if (c.provenance !== PROVENANCE_VIEWER || NON_NETWORK_SDK_METHODS.has(c.method)) continue;
998
+ if (c.method === FETCH_METHOD && c.route && isClientSafeRoute(c.route.method, c.route.path)) continue;
999
+ if (!allowed.has(c.method)) {
942
1000
  findings.push({ file, line: c.line, method: c.method, provenance: c.provenance, entry, gated });
943
1001
  }
944
1002
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [