@assembly-js/app-foundation 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -19,6 +19,13 @@ A patch release never changes what an app's build requires; a minor release may
19
19
  not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
20
20
  `bump: minor` change file.
21
21
 
22
+ ## 0.14.1
23
+ - [guidance] The build agent is taught that a call to another app through the installer client or a raw `fetch` carries the client in a client's request, that the platform can refuse it with `viewer_cannot_open_app`, and which cases the check does not cover; a route that allows only app callers answers a client who arrives through another app with 403, so it must list `client` to admit them; a `check-app-only-routes: intended` comment only tells the check script that a route should stay app-only.
24
+
25
+ ## 0.14.0
26
+ - [guidance] The build agent now counts related rows per record with `db.$count` or a joined subquery, instead of a raw correlated subselect that drizzle renders without table names and that reads 0 on every row.
27
+ - [behavior] Later edits to the app now catch a per-record count that would always show 0 (for example "0 answers" on every question) before the change is saved, and fix it in the same edit.
28
+
22
29
  ## 0.13.0
23
30
  - [new] `<ClientName client={client} />` and `<CompanyName company={company} />` from `@assembly-js/app-foundation/lib-client` render a name that opens the record's CRM page for staff and is plain text for a client viewer.
24
31
  - [new] `bun run build` runs `check:linked-names`, which fails when a client's or company's name is rendered by hand instead of through those components. It only runs on apps that carry the platform-links skill.
package/CLAUDE.md CHANGED
@@ -463,10 +463,10 @@ Relationships:
463
463
  **App isolation.** `listAppInstalls`/`retrieveAppInstall` tell you which apps are installed (id, appId, type, icon, and other install metadata), and `listAppConnections`/`createAppConnection` manage manual embed and link connections between installed apps — that's real, supported SDK surface, and requests to inspect an install or set up one of those connections are not out of bounds. None of it reaches another app's own data, though: you have no access to another installed app's database, tables, or any other internal data, and there is no SDK call that reads or writes another app's data store. If a request needs another app's private data, say only that it isn't possible and stop — no alternatives and no follow-up question. In user-facing chat, call them apps, not custom apps. Do not propose a bridge: no endpoint or API on the other app, no webhook, push, or sync, and nothing the user has to paste between chats. Do not volunteer automations, clients, companies, custom fields, or an Assembly module as a substitute. If the user explicitly asks to call an existing registered API exposed by another app, that is supported API access rather than database access; never suggest it unprompted. Likewise, if the request itself explicitly asks for automations, CRM data, or an installed module's supported data (for example invoices), that is a separate supported request. Never reach another app's database directly.
464
464
  <!-- feature:api-resources -->
465
465
 
466
- If the user names another app, read its file in `.assembly/docs/apps/` and follow the call recipe at the top of that file. If that folder is absent, the app is not callable (it may still need the Security update). Cross-app calls from pages, server actions and routes use `getInstallerAssembly()`; cron and webhooks use `getOfflineAssembly()`. Never return another app's response to a client unfiltered — scope any client data you forward. While a person is using the app, those calls use the installer client, because the platform does not yet prove which person the call is for. Once it can, use the viewer client for that person's data and keep the installer client for staff-wide reads.
466
+ If the user names another app, read its file in `.assembly/docs/apps/` and follow the call recipe at the top of that file. If that folder is absent, the app is not callable (it may still need the Security update). Cross-app calls from pages, server actions and routes use `getInstallerAssembly()`; cron and webhooks use `getOfflineAssembly()`. Never return another app's response to a client unfiltered — scope any client data you forward. In a client's request, a call to another app through the installer client or a raw `fetch` to `/v1/apps/...` carries that client: the platform checks the client could open the other app (refusal `viewer_cannot_open_app`) and the other app sees a client whose calling app is this one. A staff request, cron, a webhook or a queue sends no client. See `assembly-app-clients` for the cases the check does not cover.
467
467
  <!-- /feature:api-resources -->
468
468
  <!-- feature:cross-app-apis -->
469
- When `generated/apps/<appId>/` exists for that app, call it through the generated client instead (see the `assembly-app-clients` skill); the docs recipe is the fallback for an app with no folder there. The generated client supersedes the installer-client note above: a client contact's own calls use `viewerClient`, which the platform scopes to that contact; team members and background work use `installerClient`.
469
+ When `generated/apps/<appId>/` exists for that app, call it through the generated client instead (see the `assembly-app-clients` skill); the docs recipe is the fallback for an app with no folder there. The generated client supersedes the installer-client note above: a client contact's own calls use `viewerClient`, which the platform scopes to that contact; team members and background work use `installerClient`, which in a client's request also carries that client.
470
470
  <!-- /feature:cross-app-apis -->
471
471
 
472
472
  ---
@@ -648,6 +648,14 @@ export const items = pgTable('items', {
648
648
  - Use `drizzle-kit push` (not `migrate`) — no migration files; schema is pushed directly.
649
649
  - Use `@neondatabase/serverless` with `drizzle-orm/neon-http` — not `pg` or `postgres.js`. It's the standard driver for the Vercel serverless (Node) runtime this app deploys to; `lib/db.ts` already wires it up.
650
650
  - **No interactive transactions.** `db.transaction(async (tx) => …)` is unsupported on neon-http and throws (a 500). For atomic multi-writes use `db.batch([...])`; otherwise sequential `await`s. See the `assembly-db-transactions` skill.
651
+ - **Per-row counts: `db.$count` or a joined subquery, never a raw correlated subselect.** In a `.select({ … })` field of a query with no join, drizzle writes the columns inside a `sql` template without table names, so `(select count(*) from ${answers} where ${answers.questionId} = ${questions.id})` compares answers with itself and every row counts 0, with no error. (`.where()` is unaffected.) Use one of:
652
+ ```ts
653
+ db.select({ id: questions.id, answerCount: db.$count(answers, and(eq(answers.questionId, questions.id), eq(answers.kind, 'answer'))) }).from(questions);
654
+
655
+ const ac = db.select({ questionId: answers.questionId, c: count().as('c') }).from(answers).where(eq(answers.kind, 'answer')).groupBy(answers.questionId).as('ac');
656
+ db.select({ id: questions.id, answerCount: sql<number>`coalesce(${ac.c}, 0)` }).from(questions).leftJoin(ac, eq(ac.questionId, questions.id));
657
+ ```
658
+ `bun run build` fails on the raw form (`check:sql-subselects`).
651
659
  - **Create writes every field the create form submits.** If a later save writes nested rows (packages, add-ons, blocks), create calls that same writer with the submitted payload. A success toast and a redirect to the editor do not save. See `assembly-record-writes`.
652
660
  - `DATABASE_URL` is already in the shell env. Do NOT hardcode it or write it to `.env.local`.
653
661
  - Use `db` from `lib/db.ts` in **server components, server actions, and route handlers only** — never in client components. This is enforced: `lib/db.ts` imports `server-only`, so pulling it (or anything that imports it, e.g. a lib file re-exporting constants) into a `'use client'` module fails `bun run build`. Keep client-shared constants/types in their own file, not next to server queries.
@@ -928,7 +936,7 @@ The foundation runs the whole session; you write none of it:
928
936
 
929
937
  - `getAssembly()` — the viewer, in server components and server actions. The default for anything shown to or done for the person using the app.
930
938
  - `getAssemblyForRequest(request)` — the viewer, in a route handler; returns the client or a 401 to return as is.
931
- - `getInstallerAssembly()` — the installing admin, inside a user request, for staff-wide reads and writes the app is entitled to on its own (notification helpers, app storage, connectors). Filter what it returns before showing it to a client.
939
+ - `getInstallerAssembly()` — the installing admin, inside a user request, for staff-wide reads and writes the app is entitled to on its own (notification helpers, app storage, connectors). Filter what it returns before showing it to a client. Its calls to another app are the exception: in a client's request they carry that client.
932
940
  - `getOfflineAssembly()` — no viewer at all (cron, webhooks): an app-scoped bearer minted from `ASSEMBLY_API_KEY`. It refuses inside a viewer request.
933
941
 
934
942
  **Identity.** Branch on who the viewer is before making data calls, never by catching a 403: server-side `const id = await getSessionIdentity()` (`{ workspaceId, internalUserId?, clientId?, companyId? } | null`), client-side `useAssembly()` (`isClient`, `internalUserId`, `clientId`, seeded from the server, no loading state). A client may read only its own record and its active company.
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env node
2
+ // Fails the build when a raw `sql` template in a drizzle `.select({ … })` field
3
+ // list runs a correlated subselect against another table and the statement has
4
+ // no join. drizzle renders columns embedded in a select field as bare names when
5
+ // the query has a single table, so `where ${answers.questionId} = ${questions.id}`
6
+ // becomes `where "question_id" = "id"`: the inner table is compared with itself,
7
+ // the SQL stays valid, and a per-row count is 0 for every row. A field that names
8
+ // the template through a const in the same file is followed too.
9
+ // Dependency free on purpose: it runs inside the generated app's sandbox before
10
+ // any deps beyond Node are guaranteed.
11
+ //
12
+ // On a platform update turn (ASSEMBLY_UPDATE_TURN=1) it only warns, because an
13
+ // update never rewrites an app's queries.
14
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
15
+ import { join, relative, sep } from 'node:path';
16
+
17
+ const ROOT = process.cwd();
18
+ const UPDATE_TURN = process.env.ASSEMBLY_UPDATE_TURN === '1';
19
+ const SOURCE_DIRS = ['app', 'lib', 'components', 'src'];
20
+ const SOURCE_FILE = /\.(tsx|ts|jsx|js|mjs)$/;
21
+ const OPT_OUT = /sql-subselects:\s*qualified/;
22
+ const SELECT_OBJECT = /\.select\s*\(\s*\{/g;
23
+ const SQL_TEMPLATE = /\bsql\s*(?:<[^`;]*?>)?\s*`/g;
24
+ // `const answerCountSql = sql\`…\`` or `const answerCountSql = (…) => sql\`…\``.
25
+ const NAMED_TEMPLATE =
26
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]+)?=\s*(?:\([^)]*\)\s*(?::[^=;]+)?=>\s*)?sql\s*(?:<[^`;]*?>)?\s*`/g;
27
+ const DECLARATION = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g;
28
+ const FIELD_VALUE = /:\s*([A-Za-z_$][\w$]*)\b/g;
29
+ const SUBSELECT = /\(\s*select\b/i;
30
+ const FROM_TABLE = /\bfrom\s+\$\{\s*([A-Za-z_$][\w$]*)\s*\}/gi;
31
+ const COLUMN_REF = /\$\{\s*([A-Za-z_$][\w$]*)\.[A-Za-z_$]/g;
32
+ const JOIN = /\.(?:left|inner|right|full|cross)Join(?:Lateral)?\s*\(/;
33
+ // A line that starts a new statement; without semicolons the next query would
34
+ // otherwise be read as part of this one and its join would excuse this query.
35
+ const NEXT_STATEMENT = /^\n\s*(?:export|const|let|var|function|async|return|await|if|for|while|class|import)\b/;
36
+
37
+ function sourceFiles(dir) {
38
+ const out = [];
39
+ if (!existsSync(dir)) return out;
40
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
41
+ const full = join(dir, entry.name);
42
+ if (entry.isDirectory()) {
43
+ if (entry.name === 'node_modules' || entry.name === '.next') continue;
44
+ out.push(...sourceFiles(full));
45
+ } else if (SOURCE_FILE.test(entry.name)) {
46
+ out.push(full);
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+
52
+ // Index just past a string, template literal or comment starting at `i`, or `i`
53
+ // when none starts there. Templates recurse through their `${…}` holes.
54
+ function skipLiteral(src, i) {
55
+ const c = src[i];
56
+ if (c === '/' && src[i + 1] === '/') {
57
+ const end = src.indexOf('\n', i);
58
+ return end === -1 ? src.length : end;
59
+ }
60
+ if (c === '/' && src[i + 1] === '*') {
61
+ const end = src.indexOf('*/', i + 2);
62
+ return end === -1 ? src.length : end + 2;
63
+ }
64
+ if (c === '"' || c === "'") {
65
+ let j = i + 1;
66
+ while (j < src.length && src[j] !== c && src[j] !== '\n') j += src[j] === '\\' ? 2 : 1;
67
+ return j + 1;
68
+ }
69
+ if (c === '`') return templateEnd(src, i);
70
+ return i;
71
+ }
72
+
73
+ // Index just past the template literal whose opening backtick is at `i`.
74
+ function templateEnd(src, i) {
75
+ let j = i + 1;
76
+ while (j < src.length) {
77
+ if (src[j] === '\\') j += 2;
78
+ else if (src[j] === '`') return j + 1;
79
+ else if (src[j] === '$' && src[j + 1] === '{') j = closingIndex(src, j + 1) + 1;
80
+ else j += 1;
81
+ }
82
+ return src.length;
83
+ }
84
+
85
+ // Index of the bracket that closes the one at `i`.
86
+ function closingIndex(src, i) {
87
+ let depth = 0;
88
+ let j = i;
89
+ while (j < src.length) {
90
+ const skipped = skipLiteral(src, j);
91
+ if (skipped !== j) {
92
+ j = skipped;
93
+ continue;
94
+ }
95
+ const c = src[j];
96
+ if (c === '(' || c === '[' || c === '{') depth += 1;
97
+ else if (c === ')' || c === ']' || c === '}') {
98
+ depth -= 1;
99
+ if (depth === 0) return j;
100
+ }
101
+ j += 1;
102
+ }
103
+ return src.length;
104
+ }
105
+
106
+ // The statement a `.select(` belongs to, from it to the next `;` at depth 0, a
107
+ // new statement on a later line, or the bracket that closes the expression around it.
108
+ function statementTail(src, i) {
109
+ let depth = 0;
110
+ let j = i;
111
+ while (j < src.length) {
112
+ const skipped = skipLiteral(src, j);
113
+ if (skipped !== j) {
114
+ j = skipped;
115
+ continue;
116
+ }
117
+ const c = src[j];
118
+ if (c === '(' || c === '[' || c === '{') depth += 1;
119
+ else if (c === ')' || c === ']' || c === '}') {
120
+ depth -= 1;
121
+ if (depth < 0) break;
122
+ } else if (depth === 0 && (c === ';' || (c === '\n' && NEXT_STATEMENT.test(src.slice(j, j + 40))))) break;
123
+ j += 1;
124
+ }
125
+ return src.slice(i, j);
126
+ }
127
+
128
+ // Bodies of every `sql` template in `text`, with their offset in it.
129
+ function templatesIn(text) {
130
+ const out = [];
131
+ for (const m of text.matchAll(SQL_TEMPLATE)) {
132
+ const start = m.index + m[0].length - 1;
133
+ out.push({ body: text.slice(start, templateEnd(text, start)), offset: m.index });
134
+ }
135
+ return out;
136
+ }
137
+
138
+ function correlatesAnotherTable(body) {
139
+ if (!SUBSELECT.test(body)) return false;
140
+ const inner = new Set([...body.matchAll(FROM_TABLE)].map((m) => m[1]));
141
+ if (inner.size === 0) return false;
142
+ return [...body.matchAll(COLUMN_REF)].some((m) => !inner.has(m[1]));
143
+ }
144
+
145
+ // The source with comments and quoted strings blanked out (newlines and quotes
146
+ // kept, so offsets and line numbers still match), so a query kept in a comment
147
+ // or an example string is never checked. Template literals stay: `sql` is one.
148
+ function withoutComments(src) {
149
+ let out = '';
150
+ let i = 0;
151
+ while (i < src.length) {
152
+ const end = skipLiteral(src, i);
153
+ if (end === i) {
154
+ out += src[i];
155
+ i += 1;
156
+ continue;
157
+ }
158
+ const chunk = src.slice(i, end);
159
+ if (src[i] === '/') out += chunk.replace(/[^\n]/g, ' ');
160
+ else if (src[i] === '`') out += chunk;
161
+ else out += chunk[0] + chunk.slice(1, -1).replace(/[^\n]/g, ' ') + chunk.slice(-1);
162
+ i = end;
163
+ }
164
+ return out;
165
+ }
166
+
167
+ function lineAt(src, index) {
168
+ return src.slice(0, index).split('\n').length;
169
+ }
170
+
171
+ const files = SOURCE_DIRS.flatMap((dir) => sourceFiles(join(ROOT, dir)));
172
+ const violations = [];
173
+ for (const file of files) {
174
+ const raw = readFileSync(file, 'utf8');
175
+ if (!raw.includes('.select(') || OPT_OUT.test(raw)) continue;
176
+ const src = withoutComments(raw);
177
+
178
+ // A name declared more than once in the file cannot be tied to one template
179
+ // without scope analysis, so it is not followed rather than guessed.
180
+ const declared = new Map();
181
+ for (const m of src.matchAll(DECLARATION)) declared.set(m[1], (declared.get(m[1]) ?? 0) + 1);
182
+ const named = new Map();
183
+ for (const m of src.matchAll(NAMED_TEMPLATE)) {
184
+ if (declared.get(m[1]) > 1) continue;
185
+ const start = m.index + m[0].length - 1;
186
+ named.set(m[1], src.slice(start, templateEnd(src, start)));
187
+ }
188
+
189
+ for (const m of src.matchAll(SELECT_OBJECT)) {
190
+ const objStart = m.index + m[0].length - 1;
191
+ const fields = src.slice(objStart, closingIndex(src, objStart) + 1);
192
+ const hits = templatesIn(fields)
193
+ .filter((t) => correlatesAnotherTable(t.body))
194
+ .map((t) => objStart + t.offset);
195
+ for (const v of fields.matchAll(FIELD_VALUE)) {
196
+ const body = named.get(v[1]);
197
+ if (body && correlatesAnotherTable(body)) hits.push(objStart + v.index);
198
+ }
199
+ if (hits.length === 0 || JOIN.test(statementTail(src, m.index))) continue;
200
+ const rel = relative(ROOT, file).split(sep).join('/');
201
+ for (const at of hits) violations.push(`${rel}:${lineAt(src, at)}`);
202
+ }
203
+ }
204
+
205
+ if (violations.length > 0 && UPDATE_TURN) {
206
+ console.warn('');
207
+ console.warn('check-sql-subselects: correlated sql subselect in a single-table select (this update turn is not failed for it):');
208
+ for (const v of violations) console.warn(' - ' + v);
209
+ console.warn('');
210
+ } else if (violations.length > 0) {
211
+ console.error('');
212
+ console.error('A raw sql subselect in a .select({ … }) field reads another table, and the query has no join:');
213
+ console.error('');
214
+ for (const v of violations) console.error(' - ' + v);
215
+ console.error('');
216
+ console.error('drizzle writes the columns in that template without table names when the query has');
217
+ console.error('one table, so `${answers.questionId} = ${questions.id}` compares answers with itself');
218
+ console.error('and the count is 0 on every row. Use db.$count as the field instead:');
219
+ console.error(' answerCount: db.$count(answers, and(eq(answers.questionId, questions.id), eq(answers.kind, "answer")))');
220
+ console.error('or group the counts in a subquery (.groupBy(...).as("ac")) and .leftJoin it.');
221
+ console.error('A template whose names are already qualified (sql.raw) can add the comment');
222
+ console.error('`// sql-subselects: qualified` to the file.');
223
+ process.exit(1);
224
+ }
@@ -103,6 +103,11 @@ export declare function getOfflineAssembly(): Promise<AssemblyAPI>;
103
103
  * a second call in the same request reuses that bearer. A call through `fetch` to
104
104
  * another app (`/v1/apps/...`) is the exception: in a client's request it is made
105
105
  * as that client (see getCrossAppBearer), in a staff request as this app.
106
+ *
107
+ * The check does not cover work deferred past the response (`after()` throws
108
+ * rather than carry the bearer), an app that mints its own bearer, or a calling app
109
+ * on a foundation release before this one. A callee route that allows only app
110
+ * callers answers 403 to a client who arrives this way; see bin/check-app-only-routes.mjs.
106
111
  */
107
112
  export declare function getInstallerAssembly(): Promise<AssemblyAPI>;
108
113
  export type AppProxyCaller = 'viewer' | 'installer';
package/dist/assembly.js CHANGED
@@ -383,6 +383,11 @@ async function getInstallerBearer() {
383
383
  * a second call in the same request reuses that bearer. A call through `fetch` to
384
384
  * another app (`/v1/apps/...`) is the exception: in a client's request it is made
385
385
  * as that client (see getCrossAppBearer), in a staff request as this app.
386
+ *
387
+ * The check does not cover work deferred past the response (`after()` throws
388
+ * rather than carry the bearer), an app that mints its own bearer, or a calling app
389
+ * on a foundation release before this one. A callee route that allows only app
390
+ * callers answers 403 to a client who arrives this way; see bin/check-app-only-routes.mjs.
386
391
  */
387
392
  export async function getInstallerAssembly() {
388
393
  return getBearerAssembly(await getInstallerBearer(), undefined, {
package/dist/codegen.js CHANGED
@@ -155,7 +155,7 @@ const { data, error } = await installerClient.GET('/projects/{id}', {
155
155
  });
156
156
  \`\`\`
157
157
 
158
- - \`installerClient\` calls as this app. Use it for pages and actions a team member runs, and from cron, webhooks and background work. Filter anything you show to a client.
158
+ - \`installerClient\` calls as this app. Use it for pages and actions a team member runs, and from cron, webhooks and background work. In a client's request it also carries that client, so the platform checks the client could open this app. Filter anything you show to a client.
159
159
  - \`viewerClient\` calls as the signed-in client contact. The platform scopes the call to that contact, so do not filter what it returns. Use it for pages and actions a contact runs; it is refused for a team member.
160
160
 
161
161
  ## Operations
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -30,7 +30,11 @@ A route is not callable by other apps, so it must not appear, when it is:
30
30
 
31
31
  Every operation in the contract is proxied to other apps, so its handler must admit an app caller. Call `requireAuth` and `requireCompanyScope` with no `allow` option, or list `'app'` (`allow: ['internal', 'client', 'app']`). A route that passes only `['internal', 'client']` answers every other app with a 403, which the calling app reports as the request being refused.
32
32
 
33
- An app caller carries `callerAppId` and no `internalUserId` or `clientId`. Give it its own branch rather than mapping identity through a person-only viewer that returns null and falls into a 403. Read and write on the app's own authority with `getInstallerAssembly()`, as staff would, and never return client-private data you would not show staff. A route that must stay human-only belongs in `privateRoutes`, and stays out of this contract.
33
+ An app caller carries `callerAppId`. When the calling app is acting for itself (a staff request, cron, a webhook), there is no `internalUserId` or `clientId`: give it its own branch rather than mapping identity through a person-only viewer that returns null and falls into a 403. Read and write on the app's own authority with `getInstallerAssembly()`, as staff would, and never return client-private data you would not show staff.
34
+
35
+ When the calling app was serving a client, the call arrives as that client with `callerAppId` set, and the platform has already checked the client could open this app. A person wins over the calling app, so a route that allows only `'app'` (`allow: ['app']` or `['internal', 'app']`) answers that client 403, and a handler that takes the app branch and uses the installer lands in the client branch with a limited SDK. List `'client'` on a route such a client may reach and scope what it returns to that client. A route that should stay app-only carries a `check-app-only-routes: intended` comment on its allow list or the line above; `bin/check-app-only-routes.mjs` lists every allow list that admits `'app'` but not `'client'`.
36
+
37
+ A route that must stay human-only belongs in `privateRoutes`, and stays out of this contract.
34
38
 
35
39
  ## Worked example
36
40
 
@@ -1,7 +1,7 @@
1
1
  <!-- feature:cross-app-apis -->
2
2
  ---
3
3
  name: assembly-app-clients
4
- description: Use when the app calls another workspace app and `generated/apps/<appId>/` exists for it. Covers reading the generated README and types, calling `viewerClient` or `installerClient` by verb and path, and rendering an empty state that names the app when the call is refused (app_not_found, operation_not_found, viewer_not_allowed, callee_error) instead of throwing a 500.
4
+ description: Use when the app calls another workspace app and `generated/apps/<appId>/` exists for it. Covers reading the generated README and types, calling `viewerClient` or `installerClient` by verb and path, and rendering an empty state that names the app when the call is refused (app_not_found, operation_not_found, viewer_not_allowed, viewer_cannot_open_app, callee_error) instead of throwing a 500.
5
5
  ---
6
6
 
7
7
  ## Where the client lives
@@ -33,7 +33,13 @@ Call it from server code only: pages, server actions, route handlers, cron and w
33
33
 
34
34
  `viewerClient` calls as the signed-in client contact. The platform scopes the call to that contact, so do not filter or double-check what it returns. Use it for pages and actions a contact runs. The platform refuses it for a team member.
35
35
 
36
- `installerClient` calls as this app, not as a person. Use it for pages and actions a team member runs, and from cron, webhooks and other background work. Filter anything you show to a client.
36
+ `installerClient` calls as this app. Use it for pages and actions a team member runs, and from cron, webhooks and other background work. Filter anything you show to a client.
37
+
38
+ In a client's request it also carries that client: the platform checks the client could open the other app, and the other app sees a client whose calling app is this one. A client who cannot gets `viewer_cannot_open_app`. A staff request, cron, a webhook and a queue send no client. If the client's session cannot be verified (exchange off, a rejected header, an expired cookie) the call throws the token-invalid error rather than running as this app, and a call made where the request cannot be read (`'use cache'`, prerender, `after()`) throws; make it from a page, server action or route handler.
39
+
40
+ The check does not cover work deferred until after the response, an app that mints its own bearer instead of using this client, or a calling app on a foundation release before the one that added it. Do not rely on it to filter data.
41
+
42
+ A route in this app that allows only app callers (`allow: ['app']`) answers 403 to a client who arrives through another app, because a person wins over the calling app. Add `'client'` and scope the data to that client, or mark a route that should stay app-only with a `check-app-only-routes: intended` comment on the allow list or the line above; `bin/check-app-only-routes.mjs` lists the routes.
37
43
 
38
44
  If an operation can be called by a contact, it must not make platform calls about clients, companies or the workspace as the caller. Contacts are refused on those routes and the call surfaces as a generic failure. Look things up with the app's own access, or store the display names on the row when the item is assigned.
39
45
 
@@ -46,6 +52,7 @@ The other app can be uninstalled, can drop an operation, or can fail on its own.
46
52
  | `app_not_found` | The app is not installed in this workspace, or this app may not call it. | Empty state: "<App name> is not available in this workspace." |
47
53
  | `operation_not_found` | The app no longer offers this operation. | Empty state: "<App name> no longer provides this." |
48
54
  | `viewer_not_allowed` | The platform refused the call for this person: a team member used `viewerClient`, or a contact could not be resolved. | For a team member, call with `installerClient`. For a contact, render an empty or permission state naming the app and never retry with `installerClient`. |
55
+ | `viewer_cannot_open_app` | The client who triggered the call could not open the other app themselves, so the platform refused `installerClient` in their request. | Render an empty or permission state naming the app. Do not retry with other authority. |
49
56
  | `callee_error` | The other app answered with its own failure. The most common case at runtime. | Error state: "<App name> could not load this right now." |
50
57
 
51
58
  Branch on the result and render a state that names the app:
@@ -383,7 +383,7 @@ export const GET = automationActionRoute(async (_input, { workspaceId }) => {
383
383
 
384
384
  ## Calling another app
385
385
 
386
- If the user names another app, read its file in `.assembly/docs/apps/` and follow the call recipe at the top of that file. If that folder is absent, the app is not callable (it may still need the Security update). Cross-app calls from pages, server actions and routes use `getInstallerAssembly()`; cron and webhooks use `getOfflineAssembly()`. Never return another app's response to a client unfiltered — scope any client data you forward. While a person is using the app, those calls use the installer client, because the platform does not yet prove which person the call is for. Once it can, use the viewer client for that person's data and keep the installer client for staff-wide reads.
386
+ If the user names another app, read its file in `.assembly/docs/apps/` and follow the call recipe at the top of that file. If that folder is absent, the app is not callable (it may still need the Security update). Cross-app calls from pages, server actions and routes use `getInstallerAssembly()`; cron and webhooks use `getOfflineAssembly()`. Never return another app's response to a client unfiltered — scope any client data you forward. In a client's request, a call to another app through the installer client or a raw `fetch` to `/v1/apps/...` carries that client: the platform checks the client could open the other app (refusal `viewer_cannot_open_app`) and the other app sees a client whose calling app is this one. A staff request, cron, a webhook or a queue sends no client. See `assembly-app-clients` for the cases the check does not cover.
387
387
 
388
388
  ## Telling the user
389
389