@assembly-js/app-foundation 0.0.52 → 0.0.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/CLAUDE.md +6 -1
- package/bin/check-client-boundary.mjs +304 -0
- package/dist/app-installs.d.ts +17 -0
- package/dist/app-installs.js +34 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/package.json +1 -1
- package/skills/assembly-native-resources/SKILL.md +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,14 @@ A foundation update reads the sections between an app's version and the target:
|
|
|
14
14
|
from every bullet except `[guidance]`, agent work from the check scripts those `[action]`
|
|
15
15
|
bullets ship. Write `[fix]`, `[behavior]` and `[action]` bullets for the app's owner.
|
|
16
16
|
|
|
17
|
+
## 0.0.54
|
|
18
|
+
- [new] Add `isAppInstalled` and `getInstalledAppIds`, helpers an app can adopt to check which optional modules are installed in its workspace.
|
|
19
|
+
- [guidance] The build agent is taught to check that a module is installed before relying on features that depend on it.
|
|
20
|
+
|
|
21
|
+
## 0.0.53
|
|
22
|
+
- [action] A page or server action that calls a helper exported from a client-component file errors for every visitor who opens it; the build now reports the call, and the helper moves to a plain module both sides import.
|
|
23
|
+
- [guidance] Tell the build agent that a helper shared between a server-rendered page and a client component belongs in a plain module, not the client component's file.
|
|
24
|
+
|
|
17
25
|
## 0.0.52
|
|
18
26
|
- [guidance] The build agent is told that the CRM tab loads the app's client route as staff, with the client or company of that page in the token, so it renders a staff view scoped to that record instead of the client view or an error. It is also told to send notifications for submissions, status changes, approvals and required alerts even when the request does not mention notifications.
|
|
19
27
|
|
package/CLAUDE.md
CHANGED
|
@@ -167,7 +167,9 @@ Before adding a feature, check whether Assembly already covers it. If it does, *
|
|
|
167
167
|
|
|
168
168
|
**Always available (every workspace):** Clients, companies, and custom fields — see "The data model" below. Use them; do not rebuild them. Notification infrastructure (`listNotifications`, `createNotification`, `deleteNotification`, `markNotificationRead`, `markNotificationUnread`) is also always available.
|
|
169
169
|
|
|
170
|
-
**Optional modules (may or may not be installed in this workspace):** Messages, Files, Contracts, Forms, Billing, Knowledge Base, Tasks, and Notes. Before suggesting one or
|
|
170
|
+
**Optional modules (may or may not be installed in this workspace):** Messages, Files, Contracts, Forms, Billing, Knowledge Base, Tasks, and Notes. Before suggesting one or building on one, confirm it is installed: gate the feature with `isAppInstalled(assembly, <appId>)` from `@assembly-js/app-foundation/server` and render an honest "<app> isn't installed in this workspace" state when it is false; the plan's module list is the build-time source of truth. If it is installed, use it through the SDK; do not rebuild it. If it is not installed, do not suggest it, do not plan the app around it, and do not ask the user to install it.
|
|
171
|
+
|
|
172
|
+
**Never ask the user to test a client-facing write (a form request, message, invoice, notification) on a real client.** Suggest a throwaway test client instead, and say before the first test that a form request can't be undone from the app — it can only be removed in the dashboard.
|
|
171
173
|
|
|
172
174
|
- **Messages** — Direct messaging between staff and clients. SDK groups: Messages, MessageChannels.
|
|
173
175
|
- **Files** — Secure file/folder sharing. *For app-private uploads (attachments, exports) use the storage helpers from `@assembly-js/app-foundation/server` (see the `assembly-storage` skill) instead — Files is only for files the user wants in the portal Files tab.* SDK groups: Files, FileChannels.
|
|
@@ -670,6 +672,8 @@ If `tsc --noEmit` is clean and the task says "verify the build", run `bun run bu
|
|
|
670
672
|
|
|
671
673
|
`bun run build` also **fails** if any page reads `searchParams.token`. Auth is in the bearer cookie, not the URL — no page (not even a surface entry page) needs it; `getAssembly()` / `getSessionIdentity()` read the bearer. Fix as the error instructs — fetch that page's data via `useServerAction`/`authFetch` instead. Run `bun run check:token-usage` to check it on its own.
|
|
672
674
|
|
|
675
|
+
`bun run build` also **fails** if server code calls a function it imported from a `'use client'` file — a page, layout, or server action, or any module they import that has no directive, calling a helper that lives next to a client component. The server receives a client reference, not the function, so the page 500s at request time while `tsc` passes. Fix as the error instructs — move the helper to a plain module with no directive that both sides import. Run `bun run check:client-boundary` to check it on its own.
|
|
676
|
+
|
|
673
677
|
<!-- !feature:app-owned-header -->
|
|
674
678
|
`bun run build` also **fails** if a page route is unreachable — no link, `router.push`, redirect, or header CTA anywhere else points at it. Fix as the error instructs by adding an entry point on a reachable page. Run `bun run check:route-reachability` to check it on its own.
|
|
675
679
|
<!-- /feature:app-owned-header -->
|
|
@@ -1474,6 +1478,7 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
|
|
|
1474
1478
|
- ❌ Don't preview a PDF (or any file) in an `<iframe>`, `<embed>` or `<object>` — the portal frame is sandboxed and Chrome won't run its PDF viewer inside it, so the preview renders blank while the build passes. Open the pre-signed URL in a new tab (`assembly-storage` Pattern A) or render pages to a canvas client-side with `next/dynamic` + `ssr: false`.
|
|
1475
1479
|
- ❌ Don't call a change verified, working, or safe to publish because `tsc --noEmit` and `bun run build` passed — name the checks that ran and the signed-in screens left unopened. See "What a passing build proves" above.
|
|
1476
1480
|
- ❌ Don't pass a function — or an object that holds one, like a question/field config with `visibleWhen`, `validate`, `render`, or `format` callbacks — as a prop from a server component or page into a `'use client'` component. Props cross that boundary as serialized data, so the render throws `Functions cannot be passed directly to Client Components` and the page 500s for every visitor no matter what the record holds. `tsc --noEmit` and `bun run build` both pass. Keep such a config in a shared module the client component imports itself, or express the logic as data (`{ showWhen: { field: 'entityType', equals: 'llc' } }`) and interpret it client-side.
|
|
1481
|
+
- ❌ Don't call a function imported from a `'use client'` file from a server component, layout, or server action — e.g. an answer formatter defined in the client component file that the `/n` notification page also renders. The server gets a client reference, not the function, so the render throws and the route 500s for every visitor; `tsc --noEmit` passes. A `'use client'` file should export only components and hooks; put a shared helper in a plain module (`lib/…`) both sides import. `bun run build` fails on this (`check:client-boundary`).
|
|
1477
1482
|
- ❌ Don't use `Select` or `Combobox` to pick any Assembly person or organization record (clients, internal users, team members, staff, companies). Always use `UserCompanySelector` — in forms, filters, dropdowns, "assign to" controls, everywhere.
|
|
1478
1483
|
- ❌ Don't use `Combobox` when the user asks for a "dropdown" or "select" — use `Select`. Only use `Combobox` when the user explicitly needs type-to-search/filter functionality for non-user/non-company data.
|
|
1479
1484
|
- ❌ Don't manually render the selected value in a `Select` trigger. Use `<SelectValue placeholder="..." />` inside `SelectTrigger` **and** pass `items={options}` to the `<Select>` root — without `items`, Base UI cannot resolve labels and will display raw values (UUIDs) in the trigger. See "Select and Combobox — base-ui differences" for the full pattern.
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Fails the build when server code calls a function it imported from a
|
|
3
|
+
// 'use client' module. Next hands server code a client *reference* for every
|
|
4
|
+
// export of such a module: rendering one as JSX is fine, calling one throws
|
|
5
|
+
// ("Attempted to call X() from the server but X is on the client"), and the
|
|
6
|
+
// route answers 500 for every visitor. tsc and `next build` both pass — the
|
|
7
|
+
// module typechecks, and a dynamic page is never prerendered — so the agent's
|
|
8
|
+
// own build is the only gate that sees it before a user does.
|
|
9
|
+
//
|
|
10
|
+
// Server code is found by walking imports from Next's server entry files under
|
|
11
|
+
// app/ (page, layout, route, …) and from 'use server' modules, stopping at the
|
|
12
|
+
// first 'use client' module; everything reached without crossing that line is
|
|
13
|
+
// evaluated on the server. Parsed with the app's own TypeScript, parse-only
|
|
14
|
+
// like scan-routes.mjs (ts.createSourceFile, no Program, no type check): an
|
|
15
|
+
// import is an edge only when it survives to the emitted module, and a finding
|
|
16
|
+
// is a real call, `new`, or tagged template whose callee is an imported
|
|
17
|
+
// binding — never a name inside a string, a comment, or JSX text. A binding the
|
|
18
|
+
// file also declares locally is left alone rather than guessed at. Known
|
|
19
|
+
// limits: a binding re-exported through a barrel, and a helper in a shared
|
|
20
|
+
// module that only client code ever calls, are reported or missed by shape
|
|
21
|
+
// (the fix — a helper in a plain module — is right either way).
|
|
22
|
+
//
|
|
23
|
+
// A finding is a warning on an ordinary turn and fails the build under
|
|
24
|
+
// --strict (the update acceptance) or when ASSEMBLY_TURN_BASELINE_SHA says this
|
|
25
|
+
// turn changed a file involved: the caller, the client file, or any import on
|
|
26
|
+
// the chain that put the caller on the server — a new page that renders an
|
|
27
|
+
// existing shared component makes that component's call run on the server for
|
|
28
|
+
// the first time. A Vercel build or a template clone has no agent who can act
|
|
29
|
+
// on a failure, and the route was already broken, so neither is failed.
|
|
30
|
+
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
|
|
31
|
+
import { spawnSync } from 'node:child_process';
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
34
|
+
|
|
35
|
+
const ROOT = process.cwd();
|
|
36
|
+
const STRICT = process.argv.includes('--strict');
|
|
37
|
+
const APP_DIR = join(ROOT, 'app');
|
|
38
|
+
const SOURCE_DIRS = ['app', 'components', 'lib'];
|
|
39
|
+
const SOURCE_FILE = /\.(tsx|ts|jsx|js|mjs)$/;
|
|
40
|
+
const RESOLVE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.mjs'];
|
|
41
|
+
// Next renders or invokes these on the server unless the file opts into the client.
|
|
42
|
+
const SERVER_ENTRY_FILE = /^(page|layout|template|default|loading|not-found|route)\.(tsx|ts|jsx|js)$/;
|
|
43
|
+
// tsconfig's `@/*` → `./*`, the only alias the scaffold defines.
|
|
44
|
+
const PATH_ALIAS = '@/';
|
|
45
|
+
// The turn program exports the SHA the sandbox started this turn at; a finding
|
|
46
|
+
// is the agent's own when a file it involves changed since then.
|
|
47
|
+
const TURN_BASELINE_ENV = 'ASSEMBLY_TURN_BASELINE_SHA';
|
|
48
|
+
|
|
49
|
+
// Resolved from this file: inside an app it sits in node_modules/ or
|
|
50
|
+
// .assembly/, so this finds the app's own typescript (the one `next build`
|
|
51
|
+
// uses); in this repo it finds the package's devDependency.
|
|
52
|
+
function loadTypeScript() {
|
|
53
|
+
try {
|
|
54
|
+
return createRequire(import.meta.url)('typescript');
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sourceFiles(dir) {
|
|
61
|
+
const out = [];
|
|
62
|
+
if (!existsSync(dir)) return out;
|
|
63
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
64
|
+
const full = join(dir, entry.name);
|
|
65
|
+
if (entry.isDirectory()) {
|
|
66
|
+
if (entry.name === 'node_modules' || entry.name === '.next') continue;
|
|
67
|
+
out.push(...sourceFiles(full));
|
|
68
|
+
} else if (SOURCE_FILE.test(entry.name) && !/\.test\.\w+$/.test(entry.name)) {
|
|
69
|
+
out.push(full);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const toPosix = (file) => relative(ROOT, file).split(sep).join('/');
|
|
76
|
+
|
|
77
|
+
function resolveImport(fromFile, specifier) {
|
|
78
|
+
let base;
|
|
79
|
+
if (specifier.startsWith(PATH_ALIAS)) base = join(ROOT, specifier.slice(PATH_ALIAS.length));
|
|
80
|
+
else if (specifier.startsWith('.')) base = resolve(dirname(fromFile), specifier);
|
|
81
|
+
else return null; // a package: not this app's code
|
|
82
|
+
const candidates = [
|
|
83
|
+
base,
|
|
84
|
+
...RESOLVE_EXTENSIONS.map((ext) => base + ext),
|
|
85
|
+
...RESOLVE_EXTENSIONS.map((ext) => join(base, 'index' + ext)),
|
|
86
|
+
];
|
|
87
|
+
for (const candidate of candidates) {
|
|
88
|
+
try {
|
|
89
|
+
if (statSync(candidate).isFile()) return candidate;
|
|
90
|
+
} catch {
|
|
91
|
+
// not this candidate
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 'client' | 'server' | '' — a directive is the file's first statement, which
|
|
98
|
+
// the parser keeps as an expression statement holding the string.
|
|
99
|
+
function directiveOf(ts, sf) {
|
|
100
|
+
const first = sf.statements[0];
|
|
101
|
+
if (!first || !ts.isExpressionStatement(first) || !ts.isStringLiteral(first.expression)) return '';
|
|
102
|
+
const match = /^use (client|server)$/.exec(first.expression.text);
|
|
103
|
+
return match ? match[1] : '';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Static imports as the emitted module keeps them. TypeScript drops
|
|
107
|
+
// `import type`, a clause whose specifiers are all `type X`, and the matching
|
|
108
|
+
// `export type … from` forms, so a module reached only that way never runs.
|
|
109
|
+
function staticImports(ts, sf, file) {
|
|
110
|
+
const imports = [];
|
|
111
|
+
const edges = new Set();
|
|
112
|
+
const edge = (specifier) => {
|
|
113
|
+
const target = resolveImport(file, specifier);
|
|
114
|
+
if (target) edges.add(target);
|
|
115
|
+
return target;
|
|
116
|
+
};
|
|
117
|
+
for (const st of sf.statements) {
|
|
118
|
+
if (ts.isImportDeclaration(st) && ts.isStringLiteral(st.moduleSpecifier)) {
|
|
119
|
+
const clause = st.importClause;
|
|
120
|
+
if (!clause) {
|
|
121
|
+
edge(st.moduleSpecifier.text); // side-effect import
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (clause.isTypeOnly) continue;
|
|
125
|
+
const bindings = [];
|
|
126
|
+
if (clause.name) bindings.push({ local: clause.name.text, kind: 'default' });
|
|
127
|
+
const named = clause.namedBindings;
|
|
128
|
+
if (named && ts.isNamespaceImport(named)) bindings.push({ local: named.name.text, kind: 'namespace' });
|
|
129
|
+
if (named && ts.isNamedImports(named)) {
|
|
130
|
+
for (const el of named.elements) if (!el.isTypeOnly) bindings.push({ local: el.name.text, kind: 'named' });
|
|
131
|
+
}
|
|
132
|
+
if (bindings.length === 0) continue;
|
|
133
|
+
const target = edge(st.moduleSpecifier.text);
|
|
134
|
+
if (target) imports.push({ target, bindings });
|
|
135
|
+
} else if (ts.isExportDeclaration(st) && st.moduleSpecifier && ts.isStringLiteral(st.moduleSpecifier)) {
|
|
136
|
+
if (st.isTypeOnly) continue;
|
|
137
|
+
const clause = st.exportClause;
|
|
138
|
+
if (clause && ts.isNamedExports(clause) && clause.elements.every((el) => el.isTypeOnly)) continue;
|
|
139
|
+
edge(st.moduleSpecifier.text);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { imports, edges };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Every call-like use of an identifier (`name(…)`, `name.member(…)`, `new
|
|
146
|
+
// name(…)`, name`…`), every name the file declares itself, and the targets of
|
|
147
|
+
// dynamic `import('…')` — one walk over the tree.
|
|
148
|
+
function walkUses(ts, sf, file, edges) {
|
|
149
|
+
const calls = [];
|
|
150
|
+
const declared = new Set();
|
|
151
|
+
const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
152
|
+
const visit = (node) => {
|
|
153
|
+
if (ts.isCallExpression(node) || ts.isNewExpression(node) || ts.isTaggedTemplateExpression(node)) {
|
|
154
|
+
const callee = ts.isTaggedTemplateExpression(node) ? node.tag : node.expression;
|
|
155
|
+
if (callee.kind === ts.SyntaxKind.ImportKeyword) {
|
|
156
|
+
const arg = node.arguments?.[0];
|
|
157
|
+
if (arg && ts.isStringLiteral(arg)) {
|
|
158
|
+
const target = resolveImport(file, arg.text);
|
|
159
|
+
if (target) edges.add(target);
|
|
160
|
+
}
|
|
161
|
+
} else if (ts.isIdentifier(callee)) {
|
|
162
|
+
calls.push({ name: callee.text, member: '', line: lineOf(callee) });
|
|
163
|
+
} else if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)) {
|
|
164
|
+
calls.push({ name: callee.expression.text, member: callee.name.text, line: lineOf(callee) });
|
|
165
|
+
}
|
|
166
|
+
} else if (
|
|
167
|
+
(ts.isVariableDeclaration(node) || ts.isParameter(node) || ts.isBindingElement(node) ||
|
|
168
|
+
ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) &&
|
|
169
|
+
node.name && ts.isIdentifier(node.name)
|
|
170
|
+
) {
|
|
171
|
+
declared.add(node.name.text);
|
|
172
|
+
}
|
|
173
|
+
ts.forEachChild(node, visit);
|
|
174
|
+
};
|
|
175
|
+
visit(sf);
|
|
176
|
+
return { calls, declared };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function readModule(ts, file) {
|
|
180
|
+
const text = readFileSync(file, 'utf8');
|
|
181
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, false, file.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
182
|
+
const { imports, edges } = staticImports(ts, sf, file);
|
|
183
|
+
const { calls, declared } = walkUses(ts, sf, file, edges);
|
|
184
|
+
return { file, directive: directiveOf(ts, sf), imports, edges, calls, declared };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isServerEntry(file) {
|
|
188
|
+
return file.startsWith(APP_DIR + sep) && SERVER_ENTRY_FILE.test(file.split(sep).pop());
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// The repo-relative paths this turn changed: commits since the baseline plus
|
|
192
|
+
// whatever is still uncommitted. No baseline means no turn (a Vercel build, a
|
|
193
|
+
// clone bootstrap), so nothing is the agent's to fix. A git failure with a
|
|
194
|
+
// baseline fails closed by treating every file as changed — the sandbox is the
|
|
195
|
+
// one place a finding can still be acted on.
|
|
196
|
+
const EVERY_FILE = { has: () => true };
|
|
197
|
+
function changedThisTurn() {
|
|
198
|
+
const baseline = (process.env[TURN_BASELINE_ENV] ?? '').trim();
|
|
199
|
+
if (!baseline) return new Set();
|
|
200
|
+
const changed = new Set();
|
|
201
|
+
const listings = [
|
|
202
|
+
{ args: ['diff', '--name-only', `${baseline}..HEAD`], parse: (line) => line },
|
|
203
|
+
// Porcelain rows are "XY path", or "XY old -> new" for a rename.
|
|
204
|
+
{ args: ['status', '--porcelain', '--untracked-files=all'], parse: (line) => line.slice(3).split(' -> ').pop() },
|
|
205
|
+
];
|
|
206
|
+
for (const { args, parse } of listings) {
|
|
207
|
+
const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
|
|
208
|
+
if (result.status !== 0) return EVERY_FILE;
|
|
209
|
+
for (const line of result.stdout.split('\n')) {
|
|
210
|
+
const file = parse(line).trim();
|
|
211
|
+
if (file) changed.add(file);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return changed;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function report(findings, { fatal }) {
|
|
218
|
+
console.error('');
|
|
219
|
+
console.error(
|
|
220
|
+
fatal
|
|
221
|
+
? "Server code calls a function exported from a 'use client' file:"
|
|
222
|
+
: "Server code calls a function exported from a 'use client' file (pre-existing; this build is not failed for it):",
|
|
223
|
+
);
|
|
224
|
+
console.error('');
|
|
225
|
+
for (const f of findings) console.error(` - ${f.file}:${f.line} calls ${f.name}() imported from ${f.target}`);
|
|
226
|
+
console.error('');
|
|
227
|
+
console.error("A module that starts with 'use client' reaches server code as client references,");
|
|
228
|
+
console.error('not as functions: rendering one as JSX works, calling one throws, and the page');
|
|
229
|
+
console.error('answers 500 for every visitor. tsc and next build both pass.');
|
|
230
|
+
console.error('');
|
|
231
|
+
console.error('Move the helper into a plain module with no directive (lib/<name>.ts) and import');
|
|
232
|
+
console.error("it from both sides. A 'use client' file should export only components and hooks.");
|
|
233
|
+
if (fatal) process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!existsSync(APP_DIR)) process.exit(0);
|
|
237
|
+
|
|
238
|
+
const ts = loadTypeScript();
|
|
239
|
+
if (!ts) {
|
|
240
|
+
// Every scaffolded app depends on typescript; without it there is no parser
|
|
241
|
+
// and no `next build` either, so there is nothing for this check to guard.
|
|
242
|
+
console.error('check-client-boundary: typescript is not installed; skipped.');
|
|
243
|
+
process.exit(0);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const modules = new Map();
|
|
247
|
+
for (const dir of SOURCE_DIRS) {
|
|
248
|
+
for (const file of sourceFiles(join(ROOT, dir))) modules.set(file, readModule(ts, file));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Everything reachable from a server entry without crossing into a client
|
|
252
|
+
// module runs on the server. A client module is a boundary, not a node: what
|
|
253
|
+
// it imports is its own business.
|
|
254
|
+
const serverReachable = new Set();
|
|
255
|
+
const queue = [...modules.values()]
|
|
256
|
+
.filter((m) => m.directive === 'server' || (m.directive !== 'client' && isServerEntry(m.file)))
|
|
257
|
+
.map((m) => m.file);
|
|
258
|
+
while (queue.length > 0) {
|
|
259
|
+
const file = queue.pop();
|
|
260
|
+
if (serverReachable.has(file)) continue;
|
|
261
|
+
serverReachable.add(file);
|
|
262
|
+
for (const target of modules.get(file).edges) {
|
|
263
|
+
const next = modules.get(target);
|
|
264
|
+
if (next && next.directive !== 'client' && !serverReachable.has(target)) queue.push(target);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const findings = [];
|
|
269
|
+
for (const file of serverReachable) {
|
|
270
|
+
const mod = modules.get(file);
|
|
271
|
+
for (const { target, bindings } of mod.imports) {
|
|
272
|
+
if (modules.get(target)?.directive !== 'client') continue;
|
|
273
|
+
for (const binding of bindings) {
|
|
274
|
+
if (mod.declared.has(binding.local)) continue; // shadowed somewhere in the file
|
|
275
|
+
const call = mod.calls.find((c) =>
|
|
276
|
+
c.name === binding.local && (binding.kind === 'namespace' ? c.member !== '' : c.member === ''),
|
|
277
|
+
);
|
|
278
|
+
if (!call) continue;
|
|
279
|
+
const name = call.member ? `${call.name}.${call.member}` : call.name;
|
|
280
|
+
findings.push({ file: toPosix(file), line: call.line, target: toPosix(target), name });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (findings.length === 0) process.exit(0);
|
|
286
|
+
|
|
287
|
+
// A finding is the turn's own when the turn wrote the call, the client file, or
|
|
288
|
+
// any link in the import chain that puts the caller on the server. Walk forward
|
|
289
|
+
// from every server-side file the turn changed; what that reaches is the turn's.
|
|
290
|
+
const changed = STRICT ? EVERY_FILE : changedThisTurn();
|
|
291
|
+
const ownedByTurn = new Set();
|
|
292
|
+
const seeds = [...serverReachable].filter((file) => changed.has(toPosix(file)));
|
|
293
|
+
while (seeds.length > 0) {
|
|
294
|
+
const file = seeds.pop();
|
|
295
|
+
if (ownedByTurn.has(toPosix(file))) continue;
|
|
296
|
+
ownedByTurn.add(toPosix(file));
|
|
297
|
+
for (const target of modules.get(file).edges) {
|
|
298
|
+
if (serverReachable.has(target) && !ownedByTurn.has(toPosix(target))) seeds.push(target);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const own = findings.filter((f) => ownedByTurn.has(f.file) || changed.has(f.target));
|
|
302
|
+
const preexisting = findings.filter((f) => !own.includes(f));
|
|
303
|
+
if (preexisting.length > 0) report(preexisting, { fatal: false });
|
|
304
|
+
if (own.length > 0) report(own, { fatal: true });
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AssemblyAPI } from '@assembly-js/node-sdk';
|
|
2
|
+
/**
|
|
3
|
+
* @deprecated Built-in modules are being replaced by generated apps, so this
|
|
4
|
+
* map will be removed. Prefer an appId you resolved from the workspace; use
|
|
5
|
+
* these only to gate a feature on a module that still ships built in.
|
|
6
|
+
*/
|
|
7
|
+
export declare const LEGACY_APP_IDS: {
|
|
8
|
+
readonly messages: "38323633-6465-6132-3730-383065323230";
|
|
9
|
+
readonly billing: "64346438-6461-6431-6565-366361383434";
|
|
10
|
+
readonly files: "33343966-3136-6166-6561-343739303432";
|
|
11
|
+
readonly contracts: "34633033-3365-6561-6332-386434316430";
|
|
12
|
+
readonly forms: "66666663-3861-6633-3830-323337613065";
|
|
13
|
+
readonly helpdesk: "30643961-3563-3361-3035-396231663435";
|
|
14
|
+
readonly tasks: "0e1dff29-1ce3-40ac-b58d-264097f6b319";
|
|
15
|
+
};
|
|
16
|
+
export declare function getInstalledAppIds(assembly: AssemblyAPI): Promise<Set<string>>;
|
|
17
|
+
export declare function isAppInstalled(assembly: AssemblyAPI, appId: string): Promise<boolean>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Every node-sdk call for install lookups lives in getInstalledAppIds, so the
|
|
2
|
+
// node-sdk namespace migration is a one-line change here and never reaches
|
|
3
|
+
// generated apps.
|
|
4
|
+
/**
|
|
5
|
+
* @deprecated Built-in modules are being replaced by generated apps, so this
|
|
6
|
+
* map will be removed. Prefer an appId you resolved from the workspace; use
|
|
7
|
+
* these only to gate a feature on a module that still ships built in.
|
|
8
|
+
*/
|
|
9
|
+
export const LEGACY_APP_IDS = {
|
|
10
|
+
messages: '38323633-6465-6132-3730-383065323230',
|
|
11
|
+
billing: '64346438-6461-6431-6565-366361383434',
|
|
12
|
+
files: '33343966-3136-6166-6561-343739303432',
|
|
13
|
+
contracts: '34633033-3365-6561-6332-386434316430',
|
|
14
|
+
forms: '66666663-3861-6633-3830-323337613065',
|
|
15
|
+
helpdesk: '30643961-3563-3361-3035-396231663435',
|
|
16
|
+
tasks: '0e1dff29-1ce3-40ac-b58d-264097f6b319',
|
|
17
|
+
};
|
|
18
|
+
// A disabled install is dropped: it is present in the list but its surfaces and
|
|
19
|
+
// API behave as if the app were not there.
|
|
20
|
+
export async function getInstalledAppIds(assembly) {
|
|
21
|
+
try {
|
|
22
|
+
const installs = await assembly.listAppInstalls({});
|
|
23
|
+
return new Set(installs.filter((i) => !i.disabled && i.appId).map((i) => i.appId));
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
// Fails closed so a lookup error renders the "not installed" state instead
|
|
27
|
+
// of throwing into the page.
|
|
28
|
+
console.error('getInstalledAppIds: listAppInstalls failed', err);
|
|
29
|
+
return new Set();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function isAppInstalled(assembly, appId) {
|
|
33
|
+
return (await getInstalledAppIds(assembly)).has(appId);
|
|
34
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from './assembly.js';
|
|
|
3
3
|
export * from './action-route.js';
|
|
4
4
|
export * from './auth.js';
|
|
5
5
|
export * from './client-access.js';
|
|
6
|
+
export * from './app-installs.js';
|
|
6
7
|
export * from './mint-bearer.js';
|
|
7
8
|
export * from './verify-bearer.js';
|
|
8
9
|
export * from './nango.js';
|
package/dist/server.js
CHANGED
|
@@ -7,6 +7,7 @@ export * from './assembly.js';
|
|
|
7
7
|
export * from './action-route.js';
|
|
8
8
|
export * from './auth.js';
|
|
9
9
|
export * from './client-access.js';
|
|
10
|
+
export * from './app-installs.js';
|
|
10
11
|
export * from './mint-bearer.js';
|
|
11
12
|
export * from './verify-bearer.js';
|
|
12
13
|
export * from './nango.js';
|
package/package.json
CHANGED
|
@@ -9,6 +9,10 @@ description: Use any time the app references a native Assembly message channel,
|
|
|
9
9
|
|
|
10
10
|
Discover instead. The pattern is always the same two steps: **list, then match on a stable attribute** the workspace owner controls (a title, a name), or resolve the resource from the current request's identity. Never a literal.
|
|
11
11
|
|
|
12
|
+
## Check the app is installed first
|
|
13
|
+
|
|
14
|
+
Gate any feature that depends on another app with `isAppInstalled(assembly, <appId>)` from `@assembly-js/app-foundation/server`, and show an honest "<app> isn't installed in this workspace" state when it is false. It works the same for core, marketplace and custom apps.
|
|
15
|
+
|
|
12
16
|
## Discovering a form
|
|
13
17
|
|
|
14
18
|
A form's display name is on `fields`, not on the form itself. Walk every page — matching by name against only the first page reports the form as missing once the workspace has more than a page of forms:
|
|
@@ -31,6 +35,8 @@ await assembly.requestFormResponse({
|
|
|
31
35
|
});
|
|
32
36
|
```
|
|
33
37
|
|
|
38
|
+
**Testing a form request:** never ask the user to send one to a real client. Suggest a throwaway test client, and say before the first test that a request can't be undone from the app — it can only be removed in the dashboard.
|
|
39
|
+
|
|
34
40
|
There is **no** method for a custom app to submit answers to a native form — you can request a response from a client and read `listFormResponses`, nothing more. Build your own form (see `assembly-forms`) if the app needs to collect the answers itself.
|
|
35
41
|
|
|
36
42
|
## Discovering a message channel
|