@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 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,
@@ -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 {
@@ -9,17 +9,16 @@ const DENY_ALL = {
9
9
  isLimited: true,
10
10
  allowedCompanyIds: new Set(),
11
11
  };
12
- // Resolves the Client Access scope for an internal user.
13
- //
14
- // Fails closed: if the lookup fails we return DENY_ALL rather than treating the
15
- // viewer as unrestricted. An unverified viewer must not be handed the access of
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 access fields, so
21
- // this costs one `retrieveInternalUser` per request that needs it.
22
- export async function getClientAccessScope(internalUserId, tokenOrAssembly) {
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
- if (user.isClientAccessLimited !== true)
31
- return UNRESTRICTED;
32
- return {
33
- isLimited: true,
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 DENY_ALL;
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.28",
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.3-beta.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
  ```