@assembly-js/app-foundation 0.14.0 → 0.14.2

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,12 @@ 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.2
23
+ - [guidance] The build agent now keeps each row's own id in forms with add-and-remove rows (answer options, line items), so a selected row is no longer rejected as missing.
24
+
25
+ ## 0.14.1
26
+ - [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.
27
+
22
28
  ## 0.14.0
23
29
  - [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.
24
30
  - [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.
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
  ---
@@ -936,7 +936,7 @@ The foundation runs the whole session; you write none of it:
936
936
 
937
937
  - `getAssembly()` — the viewer, in server components and server actions. The default for anything shown to or done for the person using the app.
938
938
  - `getAssemblyForRequest(request)` — the viewer, in a route handler; returns the client or a 401 to return as is.
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.
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.
940
940
  - `getOfflineAssembly()` — no viewer at all (cron, webhooks): an app-scoped bearer minted from `ASSEMBLY_API_KEY`. It refuses inside a viewer request.
941
941
 
942
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.
@@ -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.14.0",
3
+ "version": "0.14.2",
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
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: assembly-forms
3
- description: Use when the app has multi-field forms that need client-side validation with per-field error messages. Covers react-hook-form + zod + Assembly UI Field component. NOT the Assembly Forms/intake module — for that (surveys, intake) use the SDK's listForms/retrieveForm/requestFormResponse instead.
3
+ description: Use when the app has multi-field forms that need client-side validation with per-field error messages. Covers react-hook-form + zod + Assembly UI Field component, and useFieldArray rows that carry their own id (answer options, line items). NOT the Assembly Forms/intake module — for that (surveys, intake) use the SDK's listForms/retrieveForm/requestFormResponse instead.
4
4
  ---
5
5
 
6
6
  ## Install
@@ -95,6 +95,20 @@ The installed zod is v4. Use the v4 idioms below; the v3 forms are deprecated (t
95
95
  - Flatten/format issues with the top-level `z.flattenError(err)` / `z.treeifyError(err)` — **not** `err.flatten()` / `err.format()`. Reading `err.issues` directly is fine.
96
96
  - Import from `'zod'` (top-level) — **not** the `'zod/v4'` subpath.
97
97
 
98
+ ## Dynamic rows: `useFieldArray` replaces each row's `id`
99
+
100
+ `useFieldArray` writes its own generated key into every entry of `fields` under `id`, replacing an `id` the row already has (a database id, an option id another field points at). The form values keep the row's real id, so a value read from `field.id` (a radio `value`, a `correctOptionId`, a remove-by-id) matches no row and validation rejects a visibly selected answer. Pass `keyName: 'key'` whenever rows carry their own `id`:
101
+
102
+ ```tsx
103
+ // ❌ field.id is react-hook-form's generated key, not the option's id
104
+ const { fields } = useFieldArray({ control, name: 'options' });
105
+ <RadioGroupItem key={field.id} value={field.id} />
106
+
107
+ // ✅ field.key is the generated key; field.id stays the option's id
108
+ const { fields } = useFieldArray({ control, name: 'options', keyName: 'key' });
109
+ <RadioGroupItem key={field.key} value={field.id} />
110
+ ```
111
+
98
112
  ## Key patterns
99
113
 
100
114
  - `data-invalid={!!errors.fieldName}` on `Field` triggers destructive (red) styling on the label and input border