@assembly-js/app-foundation 0.0.28 → 0.0.30
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 +1 -1
- package/bin/scan-routes.mjs +351 -0
- package/bin/scan.mjs +58 -0
- package/bin/validate-manifest.mjs +17 -2
- package/dist/client-access.d.ts +5 -0
- package/dist/client-access.js +16 -15
- package/package.json +2 -2
- package/skills/assembly-internal-users/SKILL.md +20 -2
- package/skills/assembly-route-auth/SKILL.md +2 -0
package/CLAUDE.md
CHANGED
|
@@ -20,7 +20,7 @@ When a request implies a scope or permission change, apply it and report the pro
|
|
|
20
20
|
|
|
21
21
|
Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them by name as you reach for the relevant concept — don't re-read this file's general sections when a skill exists. Available skills:
|
|
22
22
|
|
|
23
|
-
- `assembly-internal-users` — staff in the Dashboard; Client Access permissions (`isClientAccessLimited`, `companyAccessList`) and scoping internal views to allowed companies
|
|
23
|
+
- `assembly-internal-users` — staff in the Dashboard; workspace role (`admin` or `staff`) for admin-only features; Client Access permissions (`isClientAccessLimited`, `companyAccessList`) and scoping internal views to allowed companies
|
|
24
24
|
- `assembly-client-users` — customers in the Client Portal
|
|
25
25
|
- `assembly-companies` — many-to-many with clients; placeholder companies (excluded from `listCompanies` by default for these apps)
|
|
26
26
|
- `assembly-clients-companies` — REQUIRED when the app stores or scopes data by client or company: scoped tables carry BOTH a `clientId` and a `companyId` column and are filtered on the intersection (never one dimension); a client viewer always has an active `companyId`; `companyIds[]` builds pickers only and `client.companyId` is deprecated
|
|
@@ -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,
|
package/dist/client-access.d.ts
CHANGED
|
@@ -3,6 +3,11 @@ export interface ClientAccessScope {
|
|
|
3
3
|
isLimited: boolean;
|
|
4
4
|
allowedCompanyIds: Set<string>;
|
|
5
5
|
}
|
|
6
|
+
export interface InternalUserAccess {
|
|
7
|
+
isWorkspaceAdmin: boolean;
|
|
8
|
+
scope: ClientAccessScope;
|
|
9
|
+
}
|
|
10
|
+
export declare function getInternalUserAccess(internalUserId: string, tokenOrAssembly?: string | AssemblyAPI): Promise<InternalUserAccess>;
|
|
6
11
|
export declare function getClientAccessScope(internalUserId: string, tokenOrAssembly?: string | AssemblyAPI): Promise<ClientAccessScope>;
|
|
7
12
|
export declare function canAccessCompanies(scope: ClientAccessScope, companyIds: string[] | null | undefined): boolean;
|
|
8
13
|
export declare function canAccessClient<T extends {
|
package/dist/client-access.js
CHANGED
|
@@ -9,17 +9,16 @@ const DENY_ALL = {
|
|
|
9
9
|
isLimited: true,
|
|
10
10
|
allowedCompanyIds: new Set(),
|
|
11
11
|
};
|
|
12
|
-
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// a full-access one — the caller sees empty lists (or a 404 via
|
|
12
|
+
const NO_ACCESS = { isWorkspaceAdmin: false, scope: DENY_ALL };
|
|
13
|
+
// Fails closed: if the lookup fails we return NO_ACCESS rather than treating the
|
|
14
|
+
// viewer as an unrestricted admin. An unverified viewer must not be handed the
|
|
15
|
+
// access of a full-access one — the caller sees empty lists (or a 404 via
|
|
17
16
|
// canAccessCompanies) instead of another workspace member's data. Callers that
|
|
18
17
|
// prefer to surface a transient failure should catch it upstream and retry.
|
|
19
18
|
//
|
|
20
|
-
// The session token carries only `internalUserId`, never the
|
|
21
|
-
// this costs one `retrieveInternalUser` per request that needs it.
|
|
22
|
-
export async function
|
|
19
|
+
// The session token carries only `internalUserId`, never the role or access
|
|
20
|
+
// fields, so this costs one `retrieveInternalUser` per request that needs it.
|
|
21
|
+
export async function getInternalUserAccess(internalUserId, tokenOrAssembly) {
|
|
23
22
|
try {
|
|
24
23
|
// Same narrowing as requireCompanyScope: a session token string on the
|
|
25
24
|
// legacy path, an already-authenticated instance on the bearer path.
|
|
@@ -27,17 +26,19 @@ export async function getClientAccessScope(internalUserId, tokenOrAssembly) {
|
|
|
27
26
|
? await getAssembly(tokenOrAssembly)
|
|
28
27
|
: tokenOrAssembly;
|
|
29
28
|
const user = await assembly.retrieveInternalUser({ id: internalUserId });
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
allowedCompanyIds: new Set(user.companyAccessList ?? []),
|
|
35
|
-
};
|
|
29
|
+
const scope = user.isClientAccessLimited === true
|
|
30
|
+
? { isLimited: true, allowedCompanyIds: new Set(user.companyAccessList ?? []) }
|
|
31
|
+
: UNRESTRICTED;
|
|
32
|
+
return { isWorkspaceAdmin: user.role === 'admin', scope };
|
|
36
33
|
}
|
|
37
34
|
catch {
|
|
38
|
-
return
|
|
35
|
+
return NO_ACCESS;
|
|
39
36
|
}
|
|
40
37
|
}
|
|
38
|
+
// Resolves only the Client Access scope, for callers that never gate on role.
|
|
39
|
+
export async function getClientAccessScope(internalUserId, tokenOrAssembly) {
|
|
40
|
+
return (await getInternalUserAccess(internalUserId, tokenOrAssembly)).scope;
|
|
41
|
+
}
|
|
41
42
|
// True when the viewer may see a resource belonging to any of `companyIds`.
|
|
42
43
|
//
|
|
43
44
|
// Mirrors the platform's own users.User.IsAccessAllowed: access is company-scoped,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assembly-js/app-foundation",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.30",
|
|
4
4
|
"description": "Versioned foundation helpers and build context for Assembly generated apps",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@assembly-js/app-bridge": "1.3.0",
|
|
46
|
-
"@assembly-js/node-sdk": "4.2.
|
|
46
|
+
"@assembly-js/node-sdk": "4.2.4",
|
|
47
47
|
"@types/bun": "^1.1.0",
|
|
48
48
|
"@types/node": "^22",
|
|
49
49
|
"next": "^15.5.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: assembly-internal-users
|
|
3
|
-
description: Use when the app reads/lists internal users, or an internal viewer browses clients/companies in the Dashboard. Covers Client Access (isClientAccessLimited, companyAccessList) scoping. Internal users sign into the Dashboard only. Detect via tokenPayload.internalUserId.
|
|
3
|
+
description: Use when the app reads/lists internal users, gates a feature to workspace admins (role is "admin" or "staff"), or an internal viewer browses clients/companies in the Dashboard. Covers Client Access (isClientAccessLimited, companyAccessList) scoping. Internal users sign into the Dashboard only. Detect via tokenPayload.internalUserId.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Internal users are the **business team** running the Assembly workspace. They sign into the Dashboard at the workspace's domain, never the branded Client Portal. They have full read/write power over clients, companies, files, etc. — **within their Client Access scope** (see below).
|
|
@@ -23,6 +23,24 @@ If `internalUserId` is set, the current viewer is staff. (`clientId` is set for
|
|
|
23
23
|
- `listInternalUsers({ nextToken, limit })` — paged list of all staff in the workspace
|
|
24
24
|
- `retrieveInternalUser({ id })` — single record by id
|
|
25
25
|
|
|
26
|
+
## Workspace role — admins vs staff
|
|
27
|
+
|
|
28
|
+
Every internal user has a workspace role: `"admin"` or `"staff"`. The workspace owner is an admin. When the app has admin-only features ("admins manage templates", "only admins can change settings"), gate them on this role. Do not invent an app-level role table or admin list for this, and never leave the check stubbed off until a role source is "confirmed": this is the role source.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { getInternalUserAccess, getSessionIdentity } from '@assembly-js/app-foundation/server';
|
|
32
|
+
|
|
33
|
+
const identity = await getSessionIdentity();
|
|
34
|
+
if (!identity?.internalUserId) return { ok: false, message: 'Only your team can manage templates.' };
|
|
35
|
+
|
|
36
|
+
const access = await getInternalUserAccess(identity.internalUserId);
|
|
37
|
+
if (!access.isWorkspaceAdmin) {
|
|
38
|
+
return { ok: false, message: 'Only workspace admins can manage templates.' };
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Apply the same check on the server action or route, not only in the UI. `access.scope` is the viewer's Client Access scope from the same lookup (below). If the app needs its own job titles beyond admin and staff (approver, manager), store those in the app; workspace admins must still be able to manage them.
|
|
43
|
+
|
|
26
44
|
## Client Access permissions
|
|
27
45
|
|
|
28
46
|
Client Access is a **workspace-level** setting (configured in workspace settings, not inside your app). By default every internal user can see all clients and companies. Admins can limit an internal user to specific companies; that limit ripples through the platform.
|
|
@@ -36,7 +54,7 @@ Access is **company-scoped**: a limited user can see a client iff **any** of the
|
|
|
36
54
|
|
|
37
55
|
## Pre-built helpers
|
|
38
56
|
|
|
39
|
-
`@assembly-js/app-foundation/server` exports these — import them, do not rewrite the check.
|
|
57
|
+
`@assembly-js/app-foundation/server` exports these — import them, do not rewrite the check. When the request also needs the admin check, call `getInternalUserAccess` once and use its `scope` instead of a second lookup.
|
|
40
58
|
|
|
41
59
|
```ts
|
|
42
60
|
import {
|
|
@@ -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
|
```
|