@assembly-js/app-foundation 0.3.3 → 0.3.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
@@ -19,6 +19,12 @@ A patch release never changes what an app's build requires; a minor release may
19
19
  not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
20
20
  `bump: minor` change file.
21
21
 
22
+ ## 0.3.5
23
+ - [behavior] At the end of every turn the platform records which other apps' operations the app calls by literal API path in `.assembly/app-deps.json`. Publishing an app that other apps depend on is refused while that file is out of date. Nothing in the app changes.
24
+
25
+ ## 0.3.4
26
+ - [guidance] The build agent is now taught to authenticate inbound webhook calls inside the route, fail closed when the secret is unset, and never add an unauthenticated test endpoint.
27
+
22
28
  ## 0.3.3
23
29
  - [new] `notifyInternalUsers` (from `@assembly-js/app-foundation/server`) notifies every internal user, or a given `recipientIds` list, on behalf of the current viewer using the installer client.
24
30
  - [guidance] The notifications skill now directs every client-triggered send to staff through `notifyInternalUsers`, since a client's own `getAssembly()` client cannot create notifications or list staff.
package/CLAUDE.md CHANGED
@@ -1037,6 +1037,8 @@ async function saveFlavor(clientId: string, flavor: string) {
1037
1037
 
1038
1038
  Every state-changing request (POST/PUT/PATCH/DELETE) **must** go through `authFetch` (or a Next server action) — a plain `fetch` is rejected with 403 (CSRF). Never put a token or the bearer in a URL or a manual header; the cookie carries it.
1039
1039
 
1040
+ In current apps `proxy.ts` lets a request that carries no cookies skip the CSRF check, so an inbound third-party call (a webhook) needs no proxy change; an older `proxy.ts` without that rule still answers 403 to a header-less POST. Authenticate the caller inside the route (signature or shared secret), fail closed when its secret is unset, and never add an unauthenticated test endpoint.
1041
+
1040
1042
  #### Route handlers — `getAssemblyForRequest`
1041
1043
 
1042
1044
  ```ts
@@ -0,0 +1,201 @@
1
+ // Finds which other apps' operations an app calls by literal proxy path and
2
+ // describes them as `.assembly/app-deps.json`, so publish reads one file
3
+ // instead of Go parsing TypeScript.
4
+ //
5
+ // A second projection beside scan-client-calls: it reuses that detector's
6
+ // parse setup and `fetch` method reading, but looks for literals rather than
7
+ // SDK receivers. A path is reported when the file alone fixes it: a literal, or a
8
+ // template or `+` built from literals and same-file string consts (the usual way an
9
+ // app names the provider's id). Anything else is computed and left to observed traffic.
10
+ //
11
+ // `files` hashes every .ts/.tsx, not only the ones with a hit, so Go can tell
12
+ // a new file the scan never saw from one it saw and found irrelevant.
13
+ import { readFileSync } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { literalFetchMethod, scriptKind } from './scan-client-calls.mjs';
16
+ import { PROXY_METHODS, byteCompare, hashFiles, listFiles, loadTypeScript } from './scan-routes.mjs';
17
+
18
+ export const APP_DEPS_OUTPUT = '.assembly/app-deps.json';
19
+ export const CODE_SCAN_FAILED = 'app_deps_scan_failed';
20
+
21
+ // Mirrored in Go (appdepsfile): the one place a scanned source file is recognised.
22
+ const SOURCE_FILE = /\.tsx?$/;
23
+ const PREFILTER = ['/v1/apps/', 'generated/apps/'];
24
+ const PROXY_PATH = /^\/v1\/apps\/([^/?#]+)\/api(?:\/([^?#]*))?(?:[?#].*)?$/;
25
+ const VIA_PROXY_PATH = 'proxy-path';
26
+ // One file per installed provider app, written by the platform; each operation carries
27
+ // `- Method: \`X\`` then `- Path: \`/t\``. Absent for a provider that is not callable.
28
+ const PROVIDER_DOCS_DIR = '.assembly/docs/apps';
29
+ const DOC_OPERATION = /^- Method: `([A-Z]+)`\n- Path: `([^`]+)`$/m;
30
+ const DOC_CATCH_ALL = 'This operation is a catch-all.';
31
+ // An inferred route (handlers the scan could not name) has no method to record.
32
+ const INFERRED_METHOD = 'ANY';
33
+ const TEMPLATE_PARAM = /^\{[^}]+\}$/;
34
+ const CLIENT_VERBS = new Set(['get', 'post', 'put', 'patch', 'delete']);
35
+
36
+ const isLiteral = (ts, n) => ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n);
37
+
38
+ // The method a literal's call site states, or null when it is not evident.
39
+ function callSiteMethod(ts, literal) {
40
+ const call = literal.parent;
41
+ if (!ts.isCallExpression(call) || call.arguments[0] !== literal) return null;
42
+ const callee = call.expression;
43
+ const name = ts.isIdentifier(callee) ? callee.text : ts.isPropertyAccessExpression(callee) ? callee.name.text : null;
44
+ if (name === 'fetch') return literalFetchMethod(ts, call.arguments[1])?.toUpperCase() ?? null;
45
+ return CLIENT_VERBS.has(name) && ts.isPropertyAccessExpression(callee) ? name.toUpperCase() : null;
46
+ }
47
+
48
+ // A name the file binds exactly once, as a `const` initialised with a literal, is the string
49
+ // it spells; any other binding of the same name (a `let`, a parameter) may shadow it.
50
+ function constStrings(ts, sf) {
51
+ const bindings = new Map(); // name -> declaration, or null once bound a second time
52
+ const bind = (name, decl) => bindings.set(name, bindings.has(name) ? null : decl);
53
+ const walk = (node) => {
54
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) bind(node.name.text, node);
55
+ else if (ts.isParameter(node) && ts.isIdentifier(node.name)) bind(node.name.text, null);
56
+ ts.forEachChild(node, walk);
57
+ };
58
+ walk(sf);
59
+ const values = new Map();
60
+ const resolve = (node) => resolveString(ts, node, values);
61
+ // Source order, so a const built from an earlier one resolves too.
62
+ for (const [name, decl] of bindings) {
63
+ if (!decl || !(decl.parent.flags & ts.NodeFlags.Const) || !decl.initializer) continue;
64
+ const value = resolve(decl.initializer);
65
+ if (value !== null) values.set(name, value);
66
+ }
67
+ return values;
68
+ }
69
+
70
+ // The string an expression always evaluates to, or null when any part of it is not known.
71
+ function resolveString(ts, node, consts) {
72
+ if (isLiteral(ts, node)) return node.text;
73
+ if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node)) return resolveString(ts, node.expression, consts);
74
+ if (ts.isIdentifier(node)) return consts.get(node.text) ?? null;
75
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
76
+ const [left, right] = [resolveString(ts, node.left, consts), resolveString(ts, node.right, consts)];
77
+ return left !== null && right !== null ? left + right : null;
78
+ }
79
+ if (ts.isTemplateExpression(node)) {
80
+ let out = node.head.text;
81
+ for (const span of node.templateSpans) {
82
+ const value = resolveString(ts, span.expression, consts);
83
+ if (value === null) return null;
84
+ out += value + span.literal.text;
85
+ }
86
+ return out;
87
+ }
88
+ return null;
89
+ }
90
+
91
+ function collectDeps(ts, file, text) {
92
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind(ts, file));
93
+ const consts = constStrings(ts, sf);
94
+ const found = [];
95
+ const visit = (node) => {
96
+ // A bare name is read where its const is declared, not at every use.
97
+ const value = ts.isIdentifier(node) ? null : resolveString(ts, node, consts);
98
+ if (value !== null) {
99
+ const match = PROXY_PATH.exec(value);
100
+ // `'/v1/apps/x/api/items/' + id` is only the start of a path.
101
+ const concatenated = ts.isBinaryExpression(node.parent) && node.parent.left === node;
102
+ // `const BASE = `/v1/apps/${id}/api`` names the provider, it does not call its root.
103
+ const base = ts.isVariableDeclaration(node.parent) && !match?.[2];
104
+ // The proxy drops one trailing slash before routing.
105
+ const template = `/${match?.[2] ?? ''}`.replace(/(.)\/$/, '$1');
106
+ if (match && !concatenated && !base) found.push({ appId: match[1], template, method: callSiteMethod(ts, node) });
107
+ // A resolved expression is one path: its parts are not separate calls.
108
+ return;
109
+ }
110
+ ts.forEachChild(node, visit);
111
+ };
112
+ visit(sf);
113
+ return found;
114
+ }
115
+
116
+ function providerOps(root, appId) {
117
+ try {
118
+ const doc = readFileSync(path.join(root, PROVIDER_DOCS_DIR, `${appId}.md`), 'utf8');
119
+ return doc.split(/\n(?=#{3,4} )/).flatMap((section) => {
120
+ const m = DOC_OPERATION.exec(section);
121
+ return m ? [{ method: m[1], template: m[2], catchAll: section.includes(DOC_CATCH_ALL) }] : [];
122
+ });
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ // A `{param}` segment in the provider's template stands for any one segment of the call; the
129
+ // last one of a catch-all stands for one or more.
130
+ const templateMatches = ({ template, catchAll }, literal) => {
131
+ const a = template.split('/');
132
+ const b = literal.split('/');
133
+ if (!(catchAll ? b.length >= a.length : b.length === a.length)) return false;
134
+ return a.every((seg, i) => seg === b[i] || TEMPLATE_PARAM.test(seg));
135
+ };
136
+
137
+ // The proxy serves the most specific route: per segment static, then dynamic, then catch-all.
138
+ const specificity = ({ template, catchAll }, literal) => {
139
+ const a = template.split('/');
140
+ return literal.split('/').map((_, i) => {
141
+ if (i >= a.length || (catchAll && i === a.length - 1)) return 2;
142
+ return TEMPLATE_PARAM.test(a[i]) ? 1 : 0;
143
+ });
144
+ };
145
+ const bySpecificity = (literal) => (x, y) => {
146
+ const [a, b] = [specificity(x, literal), specificity(y, literal)];
147
+ const i = a.findIndex((r, k) => r !== b[k]);
148
+ return i < 0 ? 0 : a[i] - b[i];
149
+ };
150
+
151
+ // The ops one literal path stands for: the provider's winning template with every method it
152
+ // exposes, or the literal path itself when the provider's operations are not on disk.
153
+ function opsFor(provider, { template, method }) {
154
+ const matched = (provider ?? []).filter((op) => templateMatches(op, template)).sort(bySpecificity(template));
155
+ if (matched.length === 0) return (method ? [method] : PROXY_METHODS).map((m) => `${m} ${template}`);
156
+ const winner = matched.filter((op) => op.template === matched[0].template);
157
+ if (winner.every((op) => op.method === INFERRED_METHOD)) return [];
158
+ if (method) return [`${method} ${winner[0].template}`];
159
+ return winner.filter((op) => op.method !== INFERRED_METHOD).map((op) => `${op.method} ${op.template}`);
160
+ }
161
+
162
+ export function scanAppDeps(root, { loadTs = loadTypeScript } = {}) {
163
+ const files = listFiles(root).filter((f) => SOURCE_FILE.test(f)).sort(byteCompare);
164
+ const contents = new Map(files.map((f) => [f, readFileSync(path.join(root, f))]));
165
+ const candidates = files.filter((f) => PREFILTER.some((s) => contents.get(f).includes(s)));
166
+ const ops = new Map(); // appId -> Set of "<METHOD> <template>"
167
+ const providers = new Map(); // appId -> provider operations, or null
168
+ // Loaded only when there is something to parse, but a missing typescript must
169
+ // still stop the file being written for a tree that has candidates.
170
+ const ts = candidates.length ? loadTs() : null;
171
+ for (const file of candidates) {
172
+ for (const dep of collectDeps(ts, file, contents.get(file).toString('utf8'))) {
173
+ if (!ops.has(dep.appId)) ops.set(dep.appId, new Set());
174
+ if (!providers.has(dep.appId)) providers.set(dep.appId, providerOps(root, dep.appId));
175
+ for (const op of opsFor(providers.get(dep.appId), dep)) ops.get(dep.appId).add(op);
176
+ }
177
+ }
178
+ const apps = {};
179
+ for (const appId of [...ops.keys()].filter((id) => ops.get(id).size).sort(byteCompare)) {
180
+ apps[appId] = { ops: [...ops.get(appId)].sort(byteCompare), via: [VIA_PROXY_PATH] };
181
+ }
182
+ const hashes = hashFiles(root, files, contents);
183
+ return { apps, files: Object.fromEntries(files.map((f) => [f, hashes.get(f)])) };
184
+ }
185
+
186
+ export const serializeAppDeps = (result) => JSON.stringify(result, null, 2) + '\n';
187
+
188
+ // Plug-in row for bin/scan.mjs. Any failure returns a warning and no content,
189
+ // so the previous file stays and publish sees it as stale rather than trusting
190
+ // a scan that did not finish.
191
+ export const appDepsScanner = {
192
+ name: 'app-deps',
193
+ output: APP_DEPS_OUTPUT,
194
+ run: (root, options) => {
195
+ try {
196
+ return { content: serializeAppDeps(scanAppDeps(root, options)), warnings: [] };
197
+ } catch (err) {
198
+ return { warnings: [{ code: CODE_SCAN_FAILED, message: `${err && err.message ? err.message : err}` }] };
199
+ }
200
+ },
201
+ };
@@ -163,6 +163,22 @@ const isViewerSource = (name) =>
163
163
 
164
164
  const lineOf = (sf, node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
165
165
 
166
+ // The HTTP method a `fetch` init object states, shared with the cross-app scan. A missing init or
167
+ // method option is a GET; null means the method is not statically evident.
168
+ export function literalFetchMethod(ts, init) {
169
+ const literal = (n) => n && (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n));
170
+ if (!init) return DEFAULT_FETCH_HTTP_METHOD;
171
+ if (!ts.isObjectLiteralExpression(init)) return null;
172
+ let method = DEFAULT_FETCH_HTTP_METHOD;
173
+ for (const prop of init.properties) {
174
+ if (ts.isSpreadAssignment(prop) || (prop.name && ts.isComputedPropertyName(prop.name))) return null;
175
+ if (prop.name?.text !== 'method') continue;
176
+ if (!ts.isPropertyAssignment(prop) || !literal(prop.initializer)) return null;
177
+ method = prop.initializer.text;
178
+ }
179
+ return method;
180
+ }
181
+
166
182
  class Scope {
167
183
  constructor(parent) {
168
184
  this.parent = parent;
@@ -174,7 +190,7 @@ class Scope {
174
190
  }
175
191
  }
176
192
 
177
- function scriptKind(ts, file) {
193
+ export function scriptKind(ts, file) {
178
194
  if (/\.(?:tsx|jsx)$/.test(file)) return ts.ScriptKind.TSX;
179
195
  return /\.[cm]?js$/.test(file) ? ts.ScriptKind.JS : ts.ScriptKind.TS;
180
196
  }
@@ -206,16 +222,8 @@ export function collectFileFacts(ts, file, text, sdkMethods = null, localExports
206
222
  const fetchRoute = (call) => {
207
223
  const [pathArg, init] = call.arguments;
208
224
  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
- }
225
+ const method = literalFetchMethod(ts, init);
226
+ if (method === null) return null;
219
227
  // The SDK prepends the slash a path is missing, so `v1/me` requests `/v1/me`.
220
228
  const routePath = pathArg.text.startsWith('/') ? pathArg.text : `/${pathArg.text}`;
221
229
  return { path: routePath.replace(/[?#].*$/, ''), method };
@@ -80,7 +80,7 @@ const gitBlobSha = (bytes) =>
80
80
  // `git hash-object` applies the repo's clean filters (autocrlf, .gitattributes),
81
81
  // so asking git yields the blob that will actually be committed; hashing raw
82
82
  // working-tree bytes would not under those filters.
83
- function hashFiles(root, files, contents) {
83
+ export function hashFiles(root, files, contents) {
84
84
  const hashes = new Map();
85
85
  const inRepo = existsSync(path.join(root, '.git'));
86
86
  const isLink = (f) => lstatSync(path.join(root, f)).isSymbolicLink();
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 { appDepsScanner } from './scan-app-deps.mjs';
12
13
  import { clientCallScanner } from './scan-client-calls.mjs';
13
14
  import { scanRoutes, serializeRoutes } from './scan-routes.mjs';
14
15
 
@@ -21,6 +22,7 @@ const SCANNERS = [
21
22
  return { content: serializeRoutes(result), warnings: result.warnings };
22
23
  },
23
24
  },
25
+ appDepsScanner,
24
26
  ];
25
27
 
26
28
  // Scanners whose result goes to the caller, not to a committed file. They run
@@ -48,8 +50,9 @@ export function runScanners(root = process.cwd(), scanners = SCANNERS) {
48
50
  const warnings = [];
49
51
  const outputs = {};
50
52
  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;
53
+ // A scanner that failed has no content; its previous file stays, so publish sees it as stale.
54
+ if (scanner.output && content !== undefined) writeIfChanged(path.join(root, scanner.output), content);
55
+ else if (!scanner.output) outputs[scanner.name] = result;
53
56
  warnings.push(...found.map((w) => ({ scanner: scanner.name, ...w })));
54
57
  }
55
58
  return { warnings, outputs };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [