@assembly-js/app-foundation 0.0.4 → 0.0.5

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
@@ -31,7 +31,7 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
31
31
  <!-- /feature:notification-settings -->
32
32
  - `clearing-client-notifications` — REQUIRED when the app sends client notifications: clear the portal sidebar badge with markNotificationRead (clear-on-view vs clear-on-action)
33
33
  <!-- feature:api-resources -->
34
- - `assembly-automations` — REQUIRED when the request asks for a platform-side effect in reaction to an in-app event (a message, a CRM field, a contract, a form, a file — not the app's own tables), even if the user never says "automation": declare the app's API contract in `.assembly/api-resources.json` (a separate file from `app.json`) — domain events that become automation triggers + workspace webhooks, and staff-invokable operations that become automation actions. Declared events must also be emitted at runtime, reporting the acting user on the documented headers — declaring alone never fires anything. Read it before adding either.
34
+ - `assembly-automations` — REQUIRED when the request asks for a platform-side effect in reaction to an in-app event (a message, a CRM field, a contract, a form, a file — not the app's own tables), even if the user never says "automation": declare the app's API contract in `.assembly/api-resources.json` (a separate file from `app.json`) — domain events that become automation triggers + workspace webhooks, and staff-invokable operations that become automation actions. Declared events must also be emitted at runtime — inside `after()`, reporting the acting user on the documented headers — declaring alone never fires anything. Read it before adding either.
35
35
  <!-- /feature:api-resources -->
36
36
  - `assembly-app-surfaces` — REQUIRED before writing `.assembly/app.json`: declare which surfaces (dashboard, client portal, notification) the app exposes; omitting the client surface keeps the app off client portal nav entirely
37
37
  - `assembly-app-bridge-header` — breadcrumbs (don't include app name), CTAs
@@ -112,7 +112,7 @@ The app should **detect the viewer type from the token** and render audience-app
112
112
 
113
113
  **The CRM tab is never the only path to per-client data.** The CRM tab is a shortcut: it renders with a specific `clientId`/`companyId` already in the token. But any client/company-specific data or action it exposes must *also* be reachable from the Dashboard main view — even if it takes a few clicks. Build the main view so an internal user can browse to a specific client/company (a list, or a `UserCompanySelector` that drills into a per-entity detail screen) and reach the same data and actions there. The main-view detail screen doesn't have to match the CRM-tab layout — it can reuse the same components or present them differently — but the main view must never be empty or aggregate-only when the CRM tab has per-client functionality. Don't leave `internalUrl` as a dead end.
114
114
 
115
- **More generally: every page you create needs a click path.** Next.js builds and typechecks a page nothing links to, so an orphaned route ships silently. Before a page is done, something on an already-reachable page must point at it — a `<Link>`, `router.push`, redirect, header CTA, or actions-menu item. `bun run build` enforces this (`.assembly/check-route-reachability.mjs`). When a primary flow depends on managing another resource first (cases need stages, documents need templates), ship that manager's entry point together with the dependent flow — an actions-menu item on the main view plus a link on the empty state that blocks on it.
115
+ **More generally: every page you create needs a click path.** Next.js builds and typechecks a page nothing links to, so an orphaned route ships silently. Before a page is done, something on an already-reachable page must point at it — a `<Link>`, `router.push`, redirect, header CTA, or actions-menu item. `bun run build` enforces this (`bun run check:route-reachability` runs it alone). When a primary flow depends on managing another resource first (cases need stages, documents need templates), ship that manager's entry point together with the dependent flow — an actions-menu item on the main view plus a link on the empty state that blocks on it.
116
116
 
117
117
  ### Per-client app visibility — platform setting, not the app's job
118
118
 
@@ -242,7 +242,7 @@ Two paths, decided by whether an `assembly-integrations` skill is present and, i
242
242
  - `label` — short label shown above the input field.
243
243
  - `description` — 1–2 sentences telling the user where to obtain the value.
244
244
  - `type` — `url | api_key | secret | text`. The platform picks input style from this.
245
- - `docsUrl` — optional link the platform renders as "Where do I find this?".
245
+ - `docsUrl` — optional link the platform renders as "Where do I find this?". Verify it resolves (`curl -sIL <url>`, expect a 2xx — follow redirects, or a valid redirecting doc URL reads as broken) before including it — vendor docs get restructured and a stale URL renders as a dead link in the setup flow.
246
246
 
247
247
  <!-- !feature:notification-settings -->
248
248
  Valid top-level manifest keys: `surfaces`, `requiredSecrets`, and `requiredIntegrations` (leave it in place if present — it is platform-managed).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -30,12 +30,12 @@ If you are unsure, default to **internal-only**. Do not add a client surface unl
30
30
  - Generate the corresponding routes in your app code. E.g. if you declare `client.path = "/c"`, you must have a `/c` page in the app.
31
31
 
32
32
  <!-- !feature:notification-settings -->
33
- > **Every surface MUST be nested under `surfaces`.** A surface placed at the top level — e.g. `{ "surfaces": {...}, "notification": { "path": "/n" } }` — is silently dropped by the deploy parser, leaving that surface's URL empty and its notification links broken. The only valid top-level keys are `surfaces`, `requiredSecrets`, and `requiredIntegrations` (see below). The build validates this: `bun run build` runs `node .assembly/validate-manifest.mjs` first and **fails with a specific error** if the manifest is malformed or a key is misplaced. Run `bun run validate:manifest` to check it directly.
33
+ > **Every surface MUST be nested under `surfaces`.** A surface placed at the top level — e.g. `{ "surfaces": {...}, "notification": { "path": "/n" } }` — is silently dropped by the deploy parser, leaving that surface's URL empty and its notification links broken. The only valid top-level keys are `surfaces`, `requiredSecrets`, and `requiredIntegrations` (see below). The build validates this: `bun run build` runs the manifest validator first and **fails with a specific error** if the manifest is malformed or a key is misplaced. Run `bun run validate:manifest` to check it directly.
34
34
 
35
35
  `requiredSecrets` and `requiredIntegrations` are **top-level siblings of `surfaces`**, not surfaces — see the secrets guidance and the `assembly-integrations` skill. The full manifest shape is:
36
36
  <!-- /feature:notification-settings -->
37
37
  <!-- feature:notification-settings -->
38
- > **Every surface MUST be nested under `surfaces`.** A surface placed at the top level — e.g. `{ "surfaces": {...}, "notification": { "path": "/n" } }` — is silently dropped by the deploy parser, leaving that surface's URL empty and its notification links broken. The only valid top-level keys are `surfaces`, `requiredSecrets`, `notificationSettings`, and `requiredIntegrations` (see below). The build validates this: `bun run build` runs `node .assembly/validate-manifest.mjs` first and **fails with a specific error** if the manifest is malformed or a key is misplaced. Run `bun run validate:manifest` to check it directly.
38
+ > **Every surface MUST be nested under `surfaces`.** A surface placed at the top level — e.g. `{ "surfaces": {...}, "notification": { "path": "/n" } }` — is silently dropped by the deploy parser, leaving that surface's URL empty and its notification links broken. The only valid top-level keys are `surfaces`, `requiredSecrets`, `notificationSettings`, and `requiredIntegrations` (see below). The build validates this: `bun run build` runs the manifest validator first and **fails with a specific error** if the manifest is malformed or a key is misplaced. Run `bun run validate:manifest` to check it directly.
39
39
 
40
40
  `requiredSecrets`, `notificationSettings`, and `requiredIntegrations` are **top-level siblings of `surfaces`**, not surfaces — see the `assembly-notifications` skill, the secrets guidance, and the `assembly-integrations` skill. The full manifest shape is:
41
41
  <!-- /feature:notification-settings -->
@@ -1,12 +1,12 @@
1
1
  <!-- feature:api-resources -->
2
2
  ---
3
3
  name: assembly-automations
4
- description: Use when the request describes a workflow that integrates with the Assembly platform — messaging a client, updating a CRM custom field, sending a contract or form, sharing a file, anything landing on Assembly's own data rather than only the app's tables — and it should happen in reaction to something happening in the app rather than because the user pressed a button. "When a time entry is logged, message the contact" and "completing a project should update the client's status field" both qualify. Both halves are required, and the word "automation" is not: a reaction that only writes the app's own tables is ordinary app code, and so is a platform write the user triggers directly in the app UI. Also use when the request asks the app to fire webhooks, drive or react to another tool, or expose operations others can invoke. Declare those events and operations in .assembly/api-resources.json so they appear as automation triggers and actions, emit each declared event at runtime with assembly.sendWebhook, and serve each exposed operation with automationActionRoute. Covers the manifest schema, the two field shapes, event naming, the recipient-id contract, trigger configuration, dropdown option sources for action inputs, runtime event emission, and the action route contract.
4
+ description: Use when the request describes a workflow that integrates with the Assembly platform — messaging a client, updating a CRM custom field, sending a contract or form, sharing a file, anything landing on Assembly's own data rather than only the app's tables — and it should happen in reaction to something happening in the app rather than because the user pressed a button. "When a time entry is logged, message the contact" and "completing a project should update the client's status field" both qualify. Both halves are required, and the word "automation" is not: a reaction that only writes the app's own tables is ordinary app code, and so is a platform write the user triggers directly in the app UI. Also use when the request asks the app to fire webhooks, drive or react to another tool, or expose operations others can invoke. Declare those events and operations in .assembly/api-resources.json so they appear as automation triggers and actions, emit each declared event at runtime with assembly.fetch, and serve each exposed operation with automationActionRoute. Covers the manifest schema, the two field shapes, event naming, the recipient-id contract, trigger configuration, dropdown option sources for action inputs, runtime event emission, and the action route contract.
5
5
  ---
6
6
 
7
7
  An app's API contract — the events it emits and the operations workspace automations can invoke — is declared in **`.assembly/api-resources.json`** at the repo root. The platform reads it at publish and registers the app's triggers and actions. Which file owns what: `.assembly/app.json` = how the platform runs the app (surfaces, secrets, integrations); `.assembly/api-resources.json` = the API contract the app exposes. Never mix their keys.
8
8
 
9
- Declaring a webhook only registers the trigger — the running app must also **emit** the event with `assembly.sendWebhook(...)` when it happens, or automations built on it never run. See "Emitting events at runtime" below.
9
+ Declaring a webhook only registers the trigger — the running app must also **emit** the event with `assembly.fetch('/v1/webhooks/<eventName>', …)` when it happens, or automations built on it never run. See "Emitting events at runtime" below.
10
10
 
11
11
  ## When to declare it
12
12
 
@@ -207,27 +207,52 @@ Laptop Desktop Mobile Print Qrcode BarcodeRead Language EarthAmericas
207
207
 
208
208
  ## Emitting events at runtime
209
209
 
210
- Declaring a webhook only makes the trigger appear in the automations builder — **the platform never fires the event for you**. The app must report each event at the moment it happens, from the server-side code path that performs the state change:
210
+ Declaring a webhook only makes the trigger appear in the automations builder — **the platform never fires the event for you**. The app must report each event at the moment it happens, from the server-side code path that performs the state change — and report **who acted** on the same call, because the platform sees only the app's own credentials and the app is the only thing that knows the person behind the event.
211
211
 
212
212
  ```ts
213
- await assembly.sendWebhook('timeEntry.created', {
214
- id: entry.id,
215
- clientId: entry.clientId,
216
- companyId: entry.companyId,
217
- minutes: entry.minutes,
218
- startedAt: entry.startedAt.toISOString(),
213
+ import { after } from 'next/server';
214
+
215
+ const viewer = await requireAuth(req);
216
+ if (viewer instanceof NextResponse) return viewer;
217
+ // … the write commits here …
218
+
219
+ // Read the request values now — after() runs once the response is already sent.
220
+ const actorId = viewer.internalUserId ?? viewer.clientId ?? '';
221
+ const clientIP = req.headers.get('x-forwarded-for') ?? '';
222
+ const userAgent = req.headers.get('user-agent') ?? '';
223
+
224
+ after(async () => {
225
+ try {
226
+ const res = await assembly.fetch('/v1/webhooks/timeEntry.created', {
227
+ method: 'POST',
228
+ headers: {
229
+ 'X-Assembly-Actor-Id': actorId,
230
+ 'X-Assembly-Source': 'web',
231
+ 'X-Assembly-Client-IP': clientIP,
232
+ 'X-Assembly-User-Agent': userAgent,
233
+ },
234
+ body: JSON.stringify({ id: entry.id, clientId: entry.clientId, minutes: entry.minutes }),
235
+ });
236
+ if (!res.ok) {
237
+ console.error(`timeEntry.created webhook failed: ${res.status} ${await res.text()}`);
238
+ }
239
+ } catch (err) {
240
+ console.error('timeEntry.created webhook failed:', err);
241
+ }
219
242
  });
220
243
  ```
221
244
 
222
- - `sendWebhook(eventName, payload)` is on every SDK client — the request-scoped one and `getOfflineAssembly()`. The platform validates the event name and payload against this manifest's registration, then runs matching automations.
223
- - Call it **after** the database write commits, never before — an event emitted for a rolled-back write is a phantom trigger.
245
+ - **`assembly.fetch` is how an event is emitted** — it adds the app's auth and the JSON content type for you. The path is `/v1/webhooks/<eventName>` using the exact `eventName` from this file; the platform validates the name and payload against this manifest's registration, then runs matching automations.
246
+ - **Check `res.ok`.** `assembly.fetch` resolves on a 4xx instead of throwing, so an unregistered event name or an invalid payload is a silent no-op unless you read the status — the easiest way there is to ship a trigger that never fires.
247
+ - **Emit inside `after()`, and never leave the call un-awaited.** `after()` runs the emit once the response is sent, so it never delays or breaks the user's action — log the failure there and continue. Dropping the `await` to keep the handler fast looks equivalent and is not: a floating promise is frozen when the handler returns, so the event arrives minutes late or never, and which one you get is luck.
248
+ - **Capture the request values in the handler, not inside the callback.** `after()` runs past the response, so read `actorId` / IP / user-agent before it and close over them.
249
+ - Emit **after** the database write commits, never before — an event emitted for a rolled-back write is a phantom trigger.
224
250
  - The payload is the resource's record `schema[]` shape: required fields present, `fieldName`s exact. Always include the recipient fields the record declares (`clientId` / `companyId` / `internalUserId`) — automations resolve who to act on from this payload, and a missing recipient silently skips the run.
225
- - Emission must never break the user's action: wrap the call in try/catch, log the failure, and continue.
226
251
  - **Every webhook declared in this file needs at least one emit call site in the app.** A declared-but-never-emitted event is an incomplete feature: the trigger shows up in the automations UI, workspace admins build automations on it, and nothing ever runs.
227
252
 
228
253
  ### Reporting who acted
229
254
 
230
- Every event also lands in the workspace's audit log, where a workspace admin reads who did what. The platform only sees the app's own credentials on the call, so **the app is the only thing that knows the person behind the event** — capture the caller in the handler that performs the write and report it on these headers:
255
+ Every event also lands in the workspace's audit log, where a workspace admin reads who did what. The four headers above are what that entry is built from:
231
256
 
232
257
  | Header | Value |
233
258
  |---|---|
@@ -236,28 +261,9 @@ Every event also lands in the workspace's audit log, where a workspace admin rea
236
261
  | `X-Assembly-Client-IP` | the request's `x-forwarded-for` |
237
262
  | `X-Assembly-User-Agent` | the request's `user-agent` |
238
263
 
239
- `sendWebhook` cannot carry headers, so a user-triggered event is emitted through `assembly.fetch`, which adds the app's auth for you:
240
-
241
- ```ts
242
- const viewer = await requireAuth(req);
243
- if (viewer instanceof NextResponse) return viewer;
244
- // … the write commits here …
245
-
246
- await assembly.fetch('/v1/webhooks/timeEntry.created', {
247
- method: 'POST',
248
- headers: {
249
- 'X-Assembly-Actor-Id': viewer.internalUserId ?? viewer.clientId ?? '',
250
- 'X-Assembly-Source': 'web',
251
- 'X-Assembly-Client-IP': req.headers.get('x-forwarded-for') ?? '',
252
- 'X-Assembly-User-Agent': req.headers.get('user-agent') ?? '',
253
- },
254
- body: JSON.stringify({ id: entry.id, clientId: entry.clientId, minutes: entry.minutes }),
255
- });
256
- ```
257
-
258
264
  - **Report only what the request actually carries.** The platform records what it is given and infers nothing; an empty header is the same as no header.
259
265
  - **Never pass an app-internal id as the actor.** The id is recorded only once it resolves to a user of that workspace, so an app's own row id attributes the event to nobody and loses the actor entirely.
260
- - A background job has no caller — `getOfflineAssembly()` plus plain `sendWebhook(...)` is right there, and the entry reads as a system event.
266
+ - A background job has no caller and no request scope: emit with `getOfflineAssembly()`, send no actor headers, and `await` the call directly — `after()` is unavailable there. The entry reads as a system event.
261
267
 
262
268
  ## Serving actions
263
269
 
@@ -423,8 +429,10 @@ export const POST = automationActionRoute<{
423
429
 
424
430
  | Symptom | Cause | Fix |
425
431
  | --- | --- | --- |
426
- | `sendWebhook` rejects with a 400 | The event name isn't registered for this app — a typo, or the manifest hasn't been published since the event was added | Publish, then emit the exact `eventName` from this file |
427
- | `sendWebhook` 400 on a name that is registered | The payload doesn't satisfy the record's `schema[]` — a required field is absent, or carries `""` / `0` / `false` | Send every required field with a real value; declare zeroable ones `optional: true` |
432
+ | The automation never runs, or runs minutes late | The emit is un-awaited, so the handler returned and the runtime froze it before it left the app | Wrap the emit in `after()` — see "Emitting events at runtime" |
433
+ | The automation never runs and the app logged nothing | The emit came back 4xx and nothing read `res.ok` — `assembly.fetch` resolves on an error status instead of throwing | Log a non-ok response, then fix whichever cause the body names |
434
+ | The emit returns 400 | The event name isn't registered for this app — a typo, or the manifest hasn't been published since the event was added | Publish, then emit the exact `eventName` from this file |
435
+ | 400 on an event name that is registered | The payload doesn't satisfy the record's `schema[]` — a required field is absent, or carries `""` / `0` / `false` | Send every required field with a real value; declare zeroable ones `optional: true` |
428
436
  | Action returns 400 naming a field | The helper rejected the body before the handler ran: the automation's config never sends a field this manifest declares required | Make the field `automationSettings.isEnabled` so admins can fill it, or declare it `optional: true` |
429
437
  | Action step fails with no app logs at all | The app is unreachable, or nothing serves the record's `baseUrl` | Serve a real route at exactly that path — the platform calls `baseUrl`, not `apis[].path` |
430
438
  | Action returns 500 "Action is not registered" | `automationActionRoute`'s first argument doesn't match any `apis[].name` | Copy the name from this file verbatim |
@@ -257,7 +257,7 @@ Include in `deliveryTargets` every surface the setting declares; the platform re
257
257
 
258
258
  If `tsc` rejects `notificationSettingId` (an older scaffold's SDK version), run `bun add @assembly-js/node-sdk@latest` and re-check — the API accepts the field regardless.
259
259
 
260
- If `bun run build` fails with an unknown-top-level-key error on `notificationSettings`, the repo's scaffold validator predates the key: edit `.assembly/validate-manifest.mjs` and add `'notificationSettings'` to `TOP_LEVEL_KEYS`, then rerun.
260
+ If `bun run build` fails with an unknown-top-level-key error on `notificationSettings`, the validator this app runs predates the key. `package.json`'s `validate:manifest` script names the copy it runs: when that is `.assembly/validate-manifest.mjs`, add `'notificationSettings'` to its `TOP_LEVEL_KEYS` and rerun. When it resolves out of `node_modules`, the app's foundation dependency is behind — update it rather than editing an installed file.
261
261
 
262
262
  ## App-level action label (Client Home)
263
263