@assembly-js/app-foundation 0.0.3 → 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
|
|
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 (
|
|
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
|
@@ -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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
- `
|
|
223
|
-
-
|
|
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
|
|
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
|
|
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
|
-
|
|
|
427
|
-
|
|
|
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
|
|
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
|
|