@assembly-js/app-foundation 0.0.29 → 0.0.31

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/CLAUDE.md CHANGED
@@ -1162,7 +1162,7 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
1162
1162
  <!-- /feature:app-owned-header -->
1163
1163
  - ❌ Don't let two mounted components call `useAssemblyHeader` at once — e.g. a parent shell's scaffolding-era stub CTA left in place after a child took real ownership. Strict Mode hides the race in dev; the hook throws in dev and ignores the second caller in production. Delete the parent's stub the moment a child owns the slot.
1164
1164
  - ❌ Don't `window.open(url, '_self')` or set `window.location` for external links — use `<a target="_blank" rel="noreferrer">`.
1165
- - ❌ Don't open one of your own route handlers by plain navigation for a download or export — `<a href="/api/…/pdf" target="_blank">`, `window.open('/api/…')`. The bearer cookie is partitioned to the portal iframe, so a new tab arrives signed out and `requireAuth` rejects it; the build passes and the link only fails in a live test. Fetch it with `authFetch`, read the response with `res.blob()`, and click a programmatic anchor on `URL.createObjectURL(blob)` with a `download` name. (`assembly-storage` shows the same flow for a route that returns a pre-signed URL as JSON rather than the bytes.)
1165
+ - ❌ Don't open one of your own route handlers by plain navigation for a download or export — `<a href="/api/…/pdf" target="_blank">`, `window.open('/api/…')`. The bearer cookie is partitioned to the portal iframe, so a new tab arrives signed out and `requireAuth` rejects it; the build passes and the link only fails in a live test. Use `downloadViaBlob(url, filename)` from `app/components/lib-client.ts` — it calls `authFetch`, reads `res.blob()`, and triggers the download via a programmatic anchor on `URL.createObjectURL(blob)`. (`assembly-storage` shows the same flow for a route that returns a pre-signed URL as JSON rather than the bytes.)
1166
1166
  - ❌ Don't hardcode brand colors. The portal applies the workspace theme.
1167
1167
  - ❌ Don't add dark mode support (`dark:` classes, `.dark` class toggling, `next-themes`, `ThemeProvider`, or `prefers-color-scheme` media queries). The assembly-ui registry is light-only and the layout forces `colorScheme: 'light'`.
1168
1168
  - ❌ Don't handle the session token at all — no cookie you set, no `useAssembly().token`, no `x-assembly-token` header, no `searchParams.token`. The middleware already exchanged it for a bearer cookie; call `getAssembly()` / `getAssemblyForRequest(req)` server-side and the call is authenticated.
@@ -0,0 +1,351 @@
1
+ // Finds an app's API route files and describes them as `.assembly/routes.json`.
2
+ //
3
+ // The output is a pure function of the committed file contents: no timestamps,
4
+ // absolute paths or package version, so an unchanged tree rescans to the same
5
+ // bytes and a package bump never dirties every repo. The format is frozen (see
6
+ // route contract); Go re-derives the template and reserved-segment rules
7
+ // from the same constants, so change them here and there together.
8
+ //
9
+ // Parse-only on purpose: ts.createSourceFile, no Program, no type check, no
10
+ // module resolution. The scanner runs on every turn and must stay cheap and
11
+ // unable to fail on an app that does not typecheck.
12
+ import { spawnSync } from 'node:child_process';
13
+ import { createHash } from 'node:crypto';
14
+ import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync } from 'node:fs';
15
+ import { createRequire } from 'node:module';
16
+ import path from 'node:path';
17
+
18
+ export const SCHEMA_VERSION = 1;
19
+
20
+ // Shared with Go: the one place a route file is recognised.
21
+ export const ROUTE_FILE_PREDICATE = /^app\/api\/(?:.+\/)?route\.(?:ts|tsx|js|jsx)$/;
22
+ const UNSUPPORTED_ROUTE_FILE = /^(?:src\/app\/api\/(?:.+\/)?route|pages\/api\/.+)\.(?:ts|tsx|js|jsx)$/;
23
+ const API_PREFIX = 'app/api/';
24
+
25
+ // The only methods the platform proxy mounts.
26
+ export const PROXY_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
27
+ const IGNORED_METHODS = ['HEAD', 'OPTIONS'];
28
+
29
+ // Infrastructure routes are opt-out of cross-app exposure. Mirrored in Go; a
30
+ // parity test pins both lists.
31
+ export const RESERVED_SEGMENTS = ['cron', 'webhook', 'webhooks'];
32
+ export const RESERVED_SEGMENT_PREFIX = 'assembly-';
33
+ export const EXCLUDING_WRAPPERS = ['cronRoute', 'automationActionRoute'];
34
+
35
+ const APP_JSON_PATH = '.assembly/app.json';
36
+ const NEXT_CONFIG_FILES = ['next.config.js', 'next.config.mjs', 'next.config.cjs', 'next.config.ts', 'next.config.mts'];
37
+ const SKIPPED_DIRS = new Set(['node_modules', '.git', '.next', '.vercel', '.turbo']);
38
+
39
+ const byteCompare = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
40
+
41
+ function loadTypeScript() {
42
+ // Resolved from this file, so an installed package finds the app's own
43
+ // typescript and this repo's tests find the devDependency.
44
+ return createRequire(import.meta.url)('typescript');
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Tree listing and hashing
49
+ // ---------------------------------------------------------------------------
50
+
51
+ function walk(root, dir = '') {
52
+ const out = [];
53
+ for (const entry of readdirSync(path.join(root, dir), { withFileTypes: true })) {
54
+ if (SKIPPED_DIRS.has(entry.name)) continue;
55
+ const rel = dir ? `${dir}/${entry.name}` : entry.name;
56
+ if (entry.isDirectory()) out.push(...walk(root, rel));
57
+ else if (entry.isFile()) out.push(rel);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ // What `git add -A` would stage, so the scan describes the commit it rides in.
63
+ function listFiles(root) {
64
+ if (existsSync(path.join(root, '.git'))) {
65
+ const res = spawnSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], {
66
+ cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
67
+ });
68
+ if (res.status === 0) {
69
+ const listed = res.stdout.split('\0').filter(Boolean);
70
+ // A tracked file deleted from the working tree is still listed by --cached.
71
+ return [...new Set(listed)].filter((f) => existsSync(path.join(root, f)));
72
+ }
73
+ }
74
+ return walk(root);
75
+ }
76
+
77
+ const gitBlobSha = (bytes) =>
78
+ createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
79
+
80
+ // `git hash-object` applies the repo's clean filters (autocrlf, .gitattributes),
81
+ // so asking git yields the blob that will actually be committed; hashing raw
82
+ // working-tree bytes would not under those filters.
83
+ function hashFiles(root, files, contents) {
84
+ const hashes = new Map();
85
+ const inRepo = existsSync(path.join(root, '.git'));
86
+ const isLink = (f) => lstatSync(path.join(root, f)).isSymbolicLink();
87
+ // Git stores a symlink as a blob of its target path, not of the file it points at.
88
+ for (const f of files) if (isLink(f)) hashes.set(f, gitBlobSha(Buffer.from(readlinkSync(path.join(root, f)))));
89
+
90
+ const viaGit = files.filter((f) => !hashes.has(f) && !/[\n\r]/.test(f));
91
+ if (viaGit.length > 0 && inRepo) {
92
+ const res = spawnSync('git', ['hash-object', '--stdin-paths'], {
93
+ cwd: root, input: viaGit.join('\n') + '\n', encoding: 'utf8', maxBuffer: 16 * 1024 * 1024,
94
+ });
95
+ const shas = res.status === 0 ? res.stdout.split('\n').filter(Boolean) : [];
96
+ if (shas.length === viaGit.length) viaGit.forEach((f, i) => hashes.set(f, shas[i]));
97
+ else {
98
+ // The batch is all-or-nothing; retry per file so one bad path does not
99
+ // drop every other file to an unfiltered hash.
100
+ for (const f of viaGit) {
101
+ const one = spawnSync('git', ['hash-object', '--', f], { cwd: root, encoding: 'utf8' });
102
+ if (one.status === 0 && one.stdout.trim()) hashes.set(f, one.stdout.trim());
103
+ }
104
+ }
105
+ }
106
+ for (const f of files) if (!hashes.has(f)) hashes.set(f, gitBlobSha(contents.get(f)));
107
+ return hashes;
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Template derivation
112
+ // ---------------------------------------------------------------------------
113
+
114
+ const GROUP = /^\([^()]*\)$/;
115
+ const INTERCEPTING = /^\(\.{1,3}\)/;
116
+ const SLOT = /^@/;
117
+ const OPTIONAL_CATCH_ALL = /^\[\[\.\.\.([^\]]+)\]\]$/;
118
+ const CATCH_ALL = /^\[\.\.\.([^\]]+)\]$/;
119
+ const DYNAMIC = /^\[([^\].]+)\]$/;
120
+
121
+ // Pure function of the relative route-file path. Returns null for a layout the
122
+ // proxy cannot serve (parallel-route slots, intercepting routes); an optional
123
+ // catch-all yields two templates.
124
+ export function templatesForFile(file) {
125
+ const dirs = file.slice(API_PREFIX.length).split('/').slice(0, -1);
126
+ let templates = [{ segments: [], catchAll: false }];
127
+ for (const seg of dirs) {
128
+ if (SLOT.test(seg) || INTERCEPTING.test(seg)) return null;
129
+ if (GROUP.test(seg)) continue;
130
+ let m;
131
+ if ((m = OPTIONAL_CATCH_ALL.exec(seg))) {
132
+ templates = [
133
+ ...templates,
134
+ ...templates.map((t) => ({ segments: [...t.segments, `{${m[1]}}`], catchAll: true })),
135
+ ];
136
+ } else if ((m = CATCH_ALL.exec(seg))) {
137
+ templates = templates.map((t) => ({ segments: [...t.segments, `{${m[1]}}`], catchAll: true }));
138
+ } else if ((m = DYNAMIC.exec(seg))) {
139
+ templates = templates.map((t) => ({ ...t, segments: [...t.segments, `{${m[1]}}`] }));
140
+ } else {
141
+ templates = templates.map((t) => ({ ...t, segments: [...t.segments, seg] }));
142
+ }
143
+ }
144
+ return templates.map((t) => ({
145
+ template: t.segments.length ? `/${t.segments.join('/')}` : '',
146
+ ...(t.catchAll ? { catchAll: true } : {}),
147
+ }));
148
+ }
149
+
150
+ export function isReservedTemplate(template) {
151
+ return template
152
+ .split('/')
153
+ .filter(Boolean)
154
+ .some((seg) => RESERVED_SEGMENTS.includes(seg) || seg.startsWith(RESERVED_SEGMENT_PREFIX));
155
+ }
156
+
157
+ // `*` stays inside a segment, `**` crosses segments.
158
+ function globToRegExp(glob) {
159
+ let re = '';
160
+ for (let i = 0; i < glob.length; i++) {
161
+ const c = glob[i];
162
+ if (c === '*') {
163
+ if (glob[i + 1] === '*') {
164
+ re += '.*';
165
+ i++;
166
+ } else re += '[^/]*';
167
+ } else re += c.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
168
+ }
169
+ return new RegExp(`^${re}$`);
170
+ }
171
+
172
+ function readPrivateRoutes(root) {
173
+ try {
174
+ const parsed = JSON.parse(readFileSync(path.join(root, APP_JSON_PATH), 'utf8'));
175
+ const list = parsed?.privateRoutes;
176
+ return Array.isArray(list) ? list.filter((g) => typeof g === 'string').map(globToRegExp) : [];
177
+ } catch {
178
+ return [];
179
+ }
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Export analysis
184
+ // ---------------------------------------------------------------------------
185
+
186
+ const hasModifier = (ts, node, kind) => node.modifiers?.some((m) => m.kind === kind) ?? false;
187
+
188
+ // Returns the exported handler bindings and whether the export surface is
189
+ // opaque (`export *`, CommonJS) so names cannot be read statically.
190
+ function analyseExports(ts, file, text) {
191
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, false, file.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
192
+ const names = new Map(); // exported name -> initializer expression | null
193
+ let opaque = false;
194
+
195
+ const add = (name, init = null) => {
196
+ if (!names.has(name) || init) names.set(name, init);
197
+ };
198
+
199
+ // Initializers of local bindings, so `const GET = cronRoute(...); export { GET }`
200
+ // is still recognised as wrapped.
201
+ const locals = new Map();
202
+ for (const stmt of sf.statements) {
203
+ if (ts.isVariableStatement(stmt) && !hasModifier(ts, stmt, ts.SyntaxKind.ExportKeyword)) {
204
+ for (const decl of stmt.declarationList.declarations) {
205
+ if (ts.isIdentifier(decl.name) && decl.initializer) locals.set(decl.name.text, decl.initializer);
206
+ }
207
+ }
208
+ }
209
+
210
+ for (const stmt of sf.statements) {
211
+ if (ts.isFunctionDeclaration(stmt) && hasModifier(ts, stmt, ts.SyntaxKind.ExportKeyword) && stmt.name) {
212
+ add(stmt.name.text);
213
+ } else if (ts.isVariableStatement(stmt) && hasModifier(ts, stmt, ts.SyntaxKind.ExportKeyword)) {
214
+ for (const decl of stmt.declarationList.declarations) {
215
+ if (ts.isIdentifier(decl.name)) add(decl.name.text, decl.initializer ?? null);
216
+ else if (ts.isObjectBindingPattern(decl.name)) {
217
+ for (const el of decl.name.elements) if (ts.isIdentifier(el.name)) add(el.name.text);
218
+ }
219
+ }
220
+ } else if (ts.isExportDeclaration(stmt)) {
221
+ if (!stmt.exportClause) opaque = true; // export * from '...'
222
+ else if (ts.isNamedExports(stmt.exportClause)) {
223
+ for (const el of stmt.exportClause.elements) {
224
+ const local = stmt.moduleSpecifier ? null : locals.get((el.propertyName ?? el.name).text);
225
+ add(el.name.text, local ?? null);
226
+ }
227
+ } else if (ts.isNamespaceExport(stmt.exportClause)) opaque = true;
228
+ } else if (ts.isExpressionStatement(stmt) && ts.isBinaryExpression(stmt.expression)) {
229
+ const left = stmt.expression.left.getText(sf);
230
+ if (/^(?:module\.)?exports(?:\.|\[|$)/.test(left)) opaque = true;
231
+ }
232
+ }
233
+ return { names, opaque };
234
+ }
235
+
236
+ // The callee identifier of a handler initializer, matched by name only: no
237
+ // resolution, so `export const POST = cronRoute(...)` is recognised without
238
+ // following the import.
239
+ function wrapperName(ts, init) {
240
+ let node = init;
241
+ while (node && (ts.isParenthesizedExpression(node) || ts.isAwaitExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression?.(node))) {
242
+ node = node.expression;
243
+ }
244
+ if (node && ts.isCallExpression(node) && ts.isIdentifier(node.expression)) return node.expression.text;
245
+ return null;
246
+ }
247
+
248
+ function nextConfigSetsBasePath(ts, file, text) {
249
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, false);
250
+ let found = false;
251
+ const visit = (node) => {
252
+ if (found) return;
253
+ if ((ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node)) && node.name?.getText(sf) === 'basePath') {
254
+ found = true;
255
+ return;
256
+ }
257
+ ts.forEachChild(node, visit);
258
+ };
259
+ visit(sf);
260
+ return found;
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Scan
265
+ // ---------------------------------------------------------------------------
266
+
267
+ export function scanRoutes(root) {
268
+ const ts = loadTypeScript();
269
+ const all = listFiles(root);
270
+ const operations = [];
271
+ const excluded = [];
272
+ const warnings = [];
273
+ const routeFiles = [];
274
+ const contents = new Map();
275
+ const privateRoutes = readPrivateRoutes(root);
276
+
277
+ for (const file of all) {
278
+ if (UNSUPPORTED_ROUTE_FILE.test(file)) {
279
+ warnings.push({ code: 'unsupported_location', file, message: 'Route files are only read from app/api; this location is not served.' });
280
+ } else if (ROUTE_FILE_PREDICATE.test(file)) {
281
+ const templates = templatesForFile(file);
282
+ if (!templates) {
283
+ warnings.push({ code: 'unsupported_location', file, message: 'Parallel-route slots and intercepting routes are not served.' });
284
+ } else routeFiles.push({ file, templates });
285
+ }
286
+ }
287
+
288
+ for (const { file, templates } of routeFiles) {
289
+ const bytes = readFileSync(path.join(root, file));
290
+ contents.set(file, bytes);
291
+ const { names, opaque } = analyseExports(ts, file, bytes.toString('utf8'));
292
+
293
+ if (IGNORED_METHODS.some((m) => names.has(m))) {
294
+ warnings.push({ code: 'head_options_ignored', file, message: 'HEAD and OPTIONS exports are not proxied.' });
295
+ }
296
+
297
+ const wrapper = [...names.entries()]
298
+ .filter(([name]) => PROXY_METHODS.includes(name))
299
+ .map(([, init]) => (init ? wrapperName(ts, init) : null))
300
+ .find((w) => w && EXCLUDING_WRAPPERS.includes(w));
301
+
302
+ let methods;
303
+ let kind = 'static';
304
+ if (opaque) {
305
+ methods = ['ANY'];
306
+ kind = 'inferred';
307
+ warnings.push({ code: 'unresolved_export', file, message: 'Handlers are re-exported in a way that hides their names; recorded as ANY.' });
308
+ } else {
309
+ methods = PROXY_METHODS.filter((m) => names.has(m));
310
+ }
311
+
312
+ for (const { template, catchAll } of templates) {
313
+ const reason = isReservedTemplate(template)
314
+ ? 'reserved-segment'
315
+ : wrapper
316
+ ? `wrapper:${wrapper}`
317
+ : privateRoutes.some((re) => re.test(template))
318
+ ? 'private-routes'
319
+ : null;
320
+ if (reason) {
321
+ excluded.push({ template, file, reason });
322
+ continue;
323
+ }
324
+ for (const method of methods) {
325
+ operations.push({ method, template, file, ...(catchAll ? { catchAll: true } : {}), methods: kind });
326
+ }
327
+ }
328
+ }
329
+
330
+ for (const cfg of NEXT_CONFIG_FILES) {
331
+ if (!all.includes(cfg)) continue;
332
+ if (nextConfigSetsBasePath(ts, cfg, readFileSync(path.join(root, cfg), 'utf8'))) {
333
+ warnings.push({ code: 'base_path_set', file: cfg, message: 'basePath is set; route templates are not prefixed with it.' });
334
+ }
335
+ }
336
+
337
+ const files = routeFiles.map((r) => r.file);
338
+ const hashes = hashFiles(root, files, contents);
339
+
340
+ operations.sort((a, b) =>
341
+ byteCompare(a.template, b.template) || byteCompare(a.method, b.method) || byteCompare(a.file, b.file));
342
+ excluded.sort((a, b) => byteCompare(a.file, b.file) || byteCompare(a.template, b.template));
343
+ warnings.sort((a, b) => byteCompare(a.file, b.file) || byteCompare(a.code, b.code));
344
+
345
+ const sortedFiles = {};
346
+ for (const f of [...files].sort(byteCompare)) sortedFiles[f] = hashes.get(f);
347
+
348
+ return { schema: SCHEMA_VERSION, operations, excluded, warnings, files: sortedFiles };
349
+ }
350
+
351
+ export const serializeRoutes = (result) => JSON.stringify(result, null, 2) + '\n';
package/bin/scan.mjs ADDED
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ // The single per-turn scan entry point. The turn program runs this once on the
3
+ // app's main checkout before each commit; adding another scanner means adding a
4
+ // row to SCANNERS, not touching the turn program.
5
+ //
6
+ // Each scanner writes one `.assembly/*.json`, only when its bytes change so an
7
+ // unchanged tree stays a no-op commit. Prints `{"warnings":[...]}` on stdout.
8
+ // An app with no routes is not an error: it scans to an empty file and exits 0.
9
+ import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { pathToFileURL } from 'node:url';
12
+ import { scanRoutes, serializeRoutes } from './scan-routes.mjs';
13
+
14
+ const SCANNERS = [
15
+ {
16
+ name: 'routes',
17
+ output: '.assembly/routes.json',
18
+ run: (root) => {
19
+ const result = scanRoutes(root);
20
+ return { content: serializeRoutes(result), warnings: result.warnings };
21
+ },
22
+ },
23
+ ];
24
+
25
+ function writeIfChanged(file, content) {
26
+ try {
27
+ if (readFileSync(file, 'utf8') === content) return;
28
+ } catch {
29
+ // Not there yet.
30
+ }
31
+ mkdirSync(path.dirname(file), { recursive: true });
32
+ // Rename so a crash mid-write can never leave a half file for `git add -A`.
33
+ const tmp = `${file}.tmp-${process.pid}`;
34
+ writeFileSync(tmp, content);
35
+ renameSync(tmp, file);
36
+ }
37
+
38
+ export function runScanners(root = process.cwd()) {
39
+ // Every scanner runs before any file is written, so one throwing leaves all
40
+ // previous outputs intact rather than a mix of old and new.
41
+ const results = SCANNERS.map((scanner) => ({ scanner, ...scanner.run(root) }));
42
+ const warnings = [];
43
+ for (const { scanner, content, warnings: found } of results) {
44
+ writeIfChanged(path.join(root, scanner.output), content);
45
+ warnings.push(...found.map((w) => ({ scanner: scanner.name, ...w })));
46
+ }
47
+ return { warnings };
48
+ }
49
+
50
+ // argv[1] is not realpathed but import.meta.url is, so a symlinked path would skip the run.
51
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
52
+ try {
53
+ process.stdout.write(JSON.stringify(runScanners()) + '\n');
54
+ } catch (err) {
55
+ console.error(`scan: ${err && err.stack ? err.stack : err}`);
56
+ process.exit(1);
57
+ }
58
+ }
@@ -11,7 +11,7 @@ const MANIFEST = join(process.cwd(), '.assembly', 'app.json');
11
11
  // requiredIntegrations and notificationSettings are parsed by the platform's
12
12
  // deploy pipeline; rejecting them here would fail builds for manifests the
13
13
  // platform understands.
14
- const TOP_LEVEL_KEYS = ['surfaces', 'requiredSecrets', 'requiredIntegrations', 'notificationSettings'];
14
+ const TOP_LEVEL_KEYS = ['surfaces', 'requiredSecrets', 'requiredIntegrations', 'notificationSettings', 'privateRoutes'];
15
15
  const SURFACE_KEYS = ['internal', 'client', 'notification'];
16
16
  const NOTIFICATION_SURFACES = ['product', 'email'];
17
17
  const MAX_NOTIFICATION_SETTINGS = 50;
@@ -34,7 +34,7 @@ function fail(lines) {
34
34
  console.error(' "requiredIntegrations": [{ "provider": "slack", "scopes": ["chat:write"] }]');
35
35
  console.error(' }');
36
36
  console.error('Only "internal" is required. "client" and "notification" are optional and MUST live under "surfaces".');
37
- console.error('Optional top-level keys: "requiredSecrets", "requiredIntegrations", "notificationSettings" (schemas in .claude/skills).');
37
+ console.error('Optional top-level keys: "requiredSecrets", "requiredIntegrations", "notificationSettings", "privateRoutes" (schemas in .claude/skills).');
38
38
  process.exit(1);
39
39
  }
40
40
 
@@ -242,6 +242,21 @@ if (doc.notificationSettings !== undefined) {
242
242
  }
243
243
  }
244
244
 
245
+ // privateRoutes (optional; a list of glob strings scan-routes.mjs matches
246
+ // against route templates to exclude them from cross-app exposure).
247
+ if (doc.privateRoutes !== undefined) {
248
+ const pr = doc.privateRoutes;
249
+ if (!Array.isArray(pr)) {
250
+ errors.push('"privateRoutes" must be an array.');
251
+ } else {
252
+ pr.forEach((item, i) => {
253
+ if (typeof item !== 'string') {
254
+ errors.push('privateRoutes[' + i + '] must be a string (a glob like "/internal/**").');
255
+ }
256
+ });
257
+ }
258
+ }
259
+
245
260
  // requiredIntegrations (optional; mirrors the deploy-side structural rules —
246
261
  // the provider whitelist itself is enforced at deploy from the live catalog).
247
262
  // connectionScope/cardinality are reserved hinges: only their v1 values pass,
@@ -1,9 +1,21 @@
1
1
  import { getAssembly } from './assembly.js';
2
+ // A bearer token is a JWT: three base64url segments separated by dots, the
3
+ // first always decoding to a JSON header (so it starts with 'eyJ'). Matching
4
+ // the whole-string shape — not just the 'eyJ' prefix — avoids rejecting a
5
+ // legitimate file name or storage key that happens to start with 'eyJ'.
6
+ const JWT_SHAPE = /^eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$/;
2
7
  // fileName must be a bare file name (e.g. 'invoice.pdf'), never a path:
3
8
  // the platform rejects any value containing '/' or '..'. Do NOT prefix
4
9
  // folders or construct the S3 key yourself — the platform scopes the key
5
10
  // to the workspace and makes it unique. Store the key it returns.
11
+ // NOTE: takes ONE argument. If you are calling this as getUploadUrl(token, fileName)
12
+ // (the old two-arg shape), remove the token — auth is in the bearer cookie.
6
13
  export async function getUploadUrl(fileName) {
14
+ // Catches the old (token, fileName) call shape where a JWT ends up as the file name.
15
+ if (JWT_SHAPE.test(fileName)) {
16
+ throw new Error('getUploadUrl: first argument looks like a session token. ' +
17
+ 'The token argument was removed — call getUploadUrl(fileName) with the file name only.');
18
+ }
7
19
  const assembly = await getAssembly();
8
20
  const res = await assembly.fetch('/v1/app-storage/upload-url', {
9
21
  method: 'POST',
@@ -13,7 +25,14 @@ export async function getUploadUrl(fileName) {
13
25
  throw new Error('Upload URL request failed: ' + res.status);
14
26
  return res.json();
15
27
  }
28
+ // NOTE: takes ONE argument. If you are calling this as getDownloadUrl(token, key)
29
+ // (the old two-arg shape), remove the token — auth is in the bearer cookie.
16
30
  export async function getDownloadUrl(key) {
31
+ // Catches the old (token, key) call shape where a JWT ends up as the storage key.
32
+ if (JWT_SHAPE.test(key)) {
33
+ throw new Error('getDownloadUrl: first argument looks like a session token. ' +
34
+ 'The token argument was removed — call getDownloadUrl(key) with the storage key only.');
35
+ }
17
36
  const assembly = await getAssembly();
18
37
  const res = await assembly.fetch('/v1/app-storage/download-url?key=' + encodeURIComponent(key));
19
38
  if (!res.ok)
@@ -1,5 +1,6 @@
1
1
  export declare function reexchangeBearer(): Promise<boolean>;
2
2
  export declare function authFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
3
+ export declare function downloadViaBlob(url: string, filename: string): Promise<void>;
3
4
  /**
4
5
  * Mint an in-memory bearer at load when the browser is refusing to store the
5
6
  * cookie, so the first client-side navigation is already authenticated instead
@@ -126,6 +126,28 @@ export async function authFetch(input, init) {
126
126
  }
127
127
  return fetch(input, { ...init, headers });
128
128
  }
129
+ // Downloads a file from url via authFetch (bearer cookie, same-origin), then
130
+ // triggers a browser download without opening a new tab. Use this instead of
131
+ // window.open or <a download href=url>: a new top-level tab is a different cookie
132
+ // partition under CHIPS, so the bearer is absent and the route 401s.
133
+ export async function downloadViaBlob(url, filename) {
134
+ const res = await authFetch(url);
135
+ if (!res.ok)
136
+ throw new Error(`Download failed: ${res.status}`);
137
+ const blob = await res.blob();
138
+ const objectUrl = URL.createObjectURL(blob);
139
+ try {
140
+ const a = document.createElement('a');
141
+ a.href = objectUrl;
142
+ a.download = filename;
143
+ document.body.appendChild(a);
144
+ a.click();
145
+ document.body.removeChild(a);
146
+ }
147
+ finally {
148
+ URL.revokeObjectURL(objectUrl);
149
+ }
150
+ }
129
151
  /**
130
152
  * Mint an in-memory bearer at load when the browser is refusing to store the
131
153
  * cookie, so the first client-side navigation is already authenticated instead
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.29",
3
+ "version": "0.0.31",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -5,6 +5,8 @@ description: Use for every app/api/.../[id]/route.ts handler — GET and write a
5
5
 
6
6
  Every `app/api/.../[id]/route.ts` handler — **GET included** — must call `requireAuth` then `requireCompanyScope` (from `@assembly-js/app-foundation/server`) before returning or mutating data. Skipping either guard on a GET handler is a cross-tenant IDOR: an authenticated user in company A can read company B's records by changing the id in the URL.
7
7
 
8
+ Routes listed in the `privateRoutes` array of `.assembly/app.json` (globs such as `/internal/**`, matched against the route template, not the file path; `*` stays within a segment, `**` crosses segments) are not offered to other apps.
9
+
8
10
  ```ts
9
11
  import { requireAuth, requireCompanyScope, handleRouteError } from '@assembly-js/app-foundation/server';
10
12
  ```
@@ -13,8 +13,8 @@ Use app-storage for files that belong to YOUR app. For files the user expects to
13
13
 
14
14
  **Both helpers ship in `@assembly-js/app-foundation/server`, an installed dependency** — import them; do NOT recreate or rewrite them:
15
15
 
16
- - `getUploadUrl(fileName)` → `{ key, uploadUrl }` — POSTs `{ fileName }` to the platform.
17
- - `getDownloadUrl(key)` → `{ downloadUrl }` — mints a fresh pre-signed GET.
16
+ - `getUploadUrl(fileName)` → `{ key, uploadUrl }` — POSTs `{ fileName }` to the platform. Takes **one argument** (the file name only). If you were calling it as `getUploadUrl(token, fileName)` (an old two-arg shape), remove the token — auth is in the bearer cookie and the function will throw if a token is passed as the file name.
17
+ - `getDownloadUrl(key)` → `{ downloadUrl }` — mints a fresh pre-signed GET. Takes **one argument** (the storage key only). Same note: remove any leading token argument.
18
18
 
19
19
  Store the `key` it returns; never construct an S3 key yourself, and never parse the original file name back out of the key — the platform normalizes the name when forming the key, so the stored `key` is not guaranteed to contain your exact `fileName`. Keep the display name as your own metadata (see "Storing and downloading"). `fileName` must be a bare file name (`invoice.pdf`), never a path — the platform rejects any fileName containing `/` or `..`. Don't prefix folders or namespaces onto it; the platform already scopes the key to the workspace and makes it unique. If you need grouping, store that in your own database alongside the key.
20
20
 
@@ -26,7 +26,7 @@ The pre-signed URL does not lock the `Content-Type`, so any value works — set
26
26
 
27
27
  ```typescript
28
28
  'use client';
29
- import { authFetch } from './lib-client';
29
+ import { authFetch } from '@/app/components/lib-client';
30
30
 
31
31
  export async function uploadFile(file: File) {
32
32
  const res = await authFetch('/api/app-storage/upload-url', {
@@ -70,7 +70,9 @@ export async function POST(request: NextRequest) {
70
70
 
71
71
  Save the returned `key` (plus any metadata like `fileName`/`size`) in your app's own database. Pre-signed download URLs expire within hours, so generate them on demand — never embed one in rendered HTML or a cached server component.
72
72
 
73
- Create a route that returns the pre-signed URL as JSON, then trigger the download from a client component via `authFetch` + a programmatic anchor click. `authFetch` adds the CSRF header and the bearer rides the cookie, so the download route stays authenticated without any token in the URL.
73
+ ### Pattern A — route returns a pre-signed S3 URL (no proxy of bytes)
74
+
75
+ Create a route that returns the pre-signed URL as JSON, then open that URL via a programmatic anchor click. The pre-signed URL carries S3 credentials directly, so no auth cookie is needed to access S3. Use `authFetch` only for the initial request to get the URL.
74
76
 
75
77
  **Route handler** (`app/api/app-storage/download/route.ts`):
76
78
 
@@ -92,6 +94,8 @@ export async function GET(request: NextRequest) {
92
94
  **Client-side download trigger:**
93
95
 
94
96
  ```typescript
97
+ import { authFetch } from '@/app/components/lib-client';
98
+
95
99
  async function downloadFile(key: string) {
96
100
  const res = await authFetch(`/api/app-storage/download?key=${encodeURIComponent(key)}`);
97
101
  if (!res.ok) throw new Error('Failed to get download URL');
@@ -106,7 +110,18 @@ async function downloadFile(key: string) {
106
110
  }
107
111
  ```
108
112
 
109
- Do NOT use `<a href>` links to the download route — plain links can't send the CSRF header `authFetch` adds, so the route rejects them with 403. Always use a button with an `onClick` that calls `authFetch`.
113
+ ### Pattern B — route returns bytes directly (use `downloadViaBlob`)
114
+
115
+ When your route fetches from S3 and streams the bytes back instead of a pre-signed URL, use `downloadViaBlob` from `app/components/lib-client.ts`. It calls `authFetch`, reads `res.blob()`, and triggers the download via a programmatic anchor — the bearer cookie is sent because the fetch is same-origin, avoiding the CHIPS partition problem that breaks `window.open` and `<a download href=…>` to a new tab.
116
+
117
+ ```typescript
118
+ import { downloadViaBlob } from '@/app/components/lib-client';
119
+
120
+ // In a button onClick handler:
121
+ await downloadViaBlob(`/api/items/${id}/download`, 'report.pdf');
122
+ ```
123
+
124
+ Do NOT use `<a href>` links to the download route — plain links can't send the CSRF header `authFetch` adds, so the route rejects them with 403. Always use a button with an `onClick` that calls `authFetch` or `downloadViaBlob`.
110
125
 
111
126
  ## Server needs the bytes (parse / process an uploaded file)
112
127