@assembly-js/app-foundation 0.0.1 → 0.0.2-rc.3
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 +16 -0
- package/package.json +3 -2
- package/render-context.mjs +96 -0
- package/skills/assembly-app-surfaces/SKILL.md +104 -0
- package/skills/assembly-automations/SKILL.md +456 -0
- package/skills/assembly-integrations/SKILL.md +205 -0
- package/skills/assembly-notifications/SKILL.md +503 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
<!-- feature:api-resources -->
|
|
2
|
+
---
|
|
3
|
+
name: assembly-automations
|
|
4
|
+
description: Use when the request asks the app to automate something, 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. Not for an app that merely happens to have automatable mutations; if nobody asked, the file should not exist. 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
|
+
---
|
|
6
|
+
|
|
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
|
+
|
|
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.
|
|
10
|
+
|
|
11
|
+
## When to declare it
|
|
12
|
+
|
|
13
|
+
**Only when the request asked for it.** Nearly every app that writes anything has mutations that *could* be automated, so an automatable domain is not the test — someone has to have asked. What counts as asking: automate something, fire webhooks, notify or update another tool, react to something happening elsewhere, or expose operations other tools or staff workflows can invoke.
|
|
14
|
+
|
|
15
|
+
Given that, declare whichever of these the request implies:
|
|
16
|
+
|
|
17
|
+
- **Domain events**: state changes other tools should react to ("time entry logged", "request approved"). Each becomes an automation trigger and a workspace webhook event.
|
|
18
|
+
- **Staff-invokable operations**: writes an automation should be able to perform ("create a time entry", "assign a task"). Each becomes an automation action.
|
|
19
|
+
|
|
20
|
+
**If nobody asked, the file must not exist** — its absence means "this app registers no API", which is the right answer for a dashboard, a viewer, a report, and for any ordinary app whose owner never mentioned automation. Declaring anyway is not free: every exposed operation shows up as an action in front of every workspace admin in the automation builder, and every declared event has to be emitted at runtime or automations built on it silently never run.
|
|
21
|
+
|
|
22
|
+
## File format
|
|
23
|
+
|
|
24
|
+
The entire file is one top-level key. Unknown keys anywhere are publish errors, so declare exactly this vocabulary:
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"apiResources": [
|
|
29
|
+
{
|
|
30
|
+
"name": "Time Entries",
|
|
31
|
+
"resourceName": "timeEntry",
|
|
32
|
+
"baseUrl": "/api/assembly/time-entries",
|
|
33
|
+
"schema": [
|
|
34
|
+
{ "fieldName": "id", "fieldType": "string", "label": "Entry ID" },
|
|
35
|
+
{ "fieldName": "clientId", "fieldType": "string", "label": "Client", "optional": true },
|
|
36
|
+
{ "fieldName": "projectId", "fieldType": "string", "label": "Project", "optional": true },
|
|
37
|
+
{ "fieldName": "minutes", "fieldType": "number", "label": "Minutes" },
|
|
38
|
+
{ "fieldName": "startedAt", "fieldType": "string", "isDate": true, "label": "Started at" }
|
|
39
|
+
],
|
|
40
|
+
"apis": [
|
|
41
|
+
{
|
|
42
|
+
"name": "Create time entry",
|
|
43
|
+
"description": "Log a time entry",
|
|
44
|
+
"method": "POST",
|
|
45
|
+
"path": "/time-entries",
|
|
46
|
+
"input": { "type": "object", "schema": [
|
|
47
|
+
{ "fieldName": "minutes", "fieldType": "number", "label": "Minutes",
|
|
48
|
+
"automationSettings": { "isEnabled": true, "required": true, "uiType": "numberInput" } },
|
|
49
|
+
{ "fieldName": "startedAt", "fieldType": "string", "isDate": true, "label": "Started at", "optional": true,
|
|
50
|
+
"automationSettings": { "isEnabled": true, "required": false, "uiType": "dateInput" } },
|
|
51
|
+
{ "fieldName": "clientId", "fieldType": "string", "label": "Client", "optional": true,
|
|
52
|
+
"dropdownSource": { "inputType": "client", "fieldName": "clientId" },
|
|
53
|
+
"automationSettings": { "isEnabled": true, "required": false } },
|
|
54
|
+
{ "fieldName": "note", "fieldType": "string", "label": "Note", "optional": true,
|
|
55
|
+
"automationSettings": { "isEnabled": true, "required": false, "uiType": "textArea" } }
|
|
56
|
+
]},
|
|
57
|
+
"output": { "type": "object" },
|
|
58
|
+
"exposeToAutomations": true
|
|
59
|
+
}
|
|
60
|
+
],
|
|
61
|
+
"webhooks": [
|
|
62
|
+
{
|
|
63
|
+
"eventName": "timeEntry.created",
|
|
64
|
+
"eventDescription": "Fires when a time entry is logged",
|
|
65
|
+
"trigger": {
|
|
66
|
+
"name": "Time entry logged",
|
|
67
|
+
"description": "Triggers when a new time entry is created",
|
|
68
|
+
"icon": "Time",
|
|
69
|
+
"snapshotCxt": [ { "label": "Minutes", "property": "minutes" } ]
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"eventName": "timeEntry.approved",
|
|
74
|
+
"eventDescription": "Fires when a time entry is approved",
|
|
75
|
+
"trigger": {
|
|
76
|
+
"name": "Time entry approved",
|
|
77
|
+
"description": "Triggers when a time entry is approved, optionally for one project",
|
|
78
|
+
"icon": "Success",
|
|
79
|
+
"configuration": [
|
|
80
|
+
{
|
|
81
|
+
"id": "1",
|
|
82
|
+
"label": "Project",
|
|
83
|
+
"description": "Only fire for this project",
|
|
84
|
+
"fieldProperty": "projectId",
|
|
85
|
+
"fieldType": "string",
|
|
86
|
+
"fieldOperator": "equals",
|
|
87
|
+
"type": "dropdown",
|
|
88
|
+
"required": true,
|
|
89
|
+
"addAnyOption": true,
|
|
90
|
+
"dynamicOptions": { "requestUrl": "/projects" }
|
|
91
|
+
}
|
|
92
|
+
],
|
|
93
|
+
"snapshotCxt": [ { "label": "Minutes", "property": "minutes" } ]
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
]
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"name": "Projects",
|
|
100
|
+
"resourceName": "project",
|
|
101
|
+
"baseUrl": "/api/assembly/projects",
|
|
102
|
+
"schema": [
|
|
103
|
+
{ "fieldName": "id", "fieldType": "string", "label": "Project ID" },
|
|
104
|
+
{ "fieldName": "name", "fieldType": "string", "label": "Name" }
|
|
105
|
+
],
|
|
106
|
+
"apis": [
|
|
107
|
+
{ "name": "List projects", "description": "List projects for dropdowns",
|
|
108
|
+
"method": "GET", "path": "/projects", "output": { "type": "list" } }
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
- One array element = one **resource**: a collection the app exposes (`resourceName` is its stable camelCase identity, e.g. `timeEntry`).
|
|
116
|
+
- `baseUrl` is a **relative path** into this app (usually `/api/assembly/<kebab-resource>`) — never an absolute URL. At publish the platform resolves it against the app's deployed origin, so the registered record carries the full callable URL; the app just serves a real route there.
|
|
117
|
+
- **`apis[].path` is an app-global route-matching key, not a URL segment appended to `baseUrl`.** The platform matches an incoming operation against every record's `(method, path)` pairs, then forwards to the *matching record's* `baseUrl`. Keep each record's api paths named after its own collection, and serve a real route at each record's `baseUrl` in the app.
|
|
118
|
+
- **By-id operations** declare a single trailing `/:id` (e.g. `"path": "/time-entries/:id"`). On the public-API path the platform appends the target record's id to the record's `baseUrl` — the app serves it as a normal dynamic route (`baseUrl`/`[id]`). An **automation** never appends an id (see "Serving actions"), so an operation exposed to automations must take everything it needs from `input.schema`.
|
|
119
|
+
- Record-level `schema[]` is the resource's **data shape** — the fields an event payload for this resource carries. Only `fieldName`, `fieldType` (`string`/`number`/`bool`/`object`/`array`), `label`, `description`, `isDate`, `optional`, and `nestedFieldSchema` are valid here.
|
|
120
|
+
- **`nestedFieldSchema`** optionally describes the children of an `object` field (or of an `array`'s elements) — same keys, recursive, max 3 levels counting the record's `schema[]` as the first. It is valid **only** on record-level `schema[]`; on `apis[].input.schema[]` it fails the publish.
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{ "fieldName": "lineItems", "fieldType": "array", "label": "Line items",
|
|
124
|
+
"nestedFieldSchema": [
|
|
125
|
+
{ "fieldName": "sku", "fieldType": "string", "label": "SKU" },
|
|
126
|
+
{ "fieldName": "quantity", "fieldType": "number", "label": "Quantity" }
|
|
127
|
+
] }
|
|
128
|
+
```
|
|
129
|
+
- `apis[].input.schema[]` fields additionally take the **action-config UI keys**: `placeholder`, static `options` (`[{id, name}]`), `dynamicOptions`, `dropdownSource`, and `automationSettings {isEnabled, required, uiType}`. Placing any of these on record-level `schema[]` fails the publish — the two positions have different shapes on purpose.
|
|
130
|
+
- **`optional` and `automationSettings.required` are independent contracts.** `optional` is the data shape: may the field be absent from payloads and request bodies. `automationSettings.required` is the config UI: must a workspace admin fill this field when configuring the action. They don't need to agree.
|
|
131
|
+
- **`automationSettings.uiType`** picks the widget the automation config UI renders, and is **required for every exposed field** (`isEnabled: true`) — with two exceptions that are already unambiguous: fields with an options source (`options`, `dynamicOptions`, or `dropdownSource`) are dropdowns, and `fieldType: "bool"` fields are toggles. A `uiType` on a bool field fails the publish. Pick by what the field holds, because **the uiType decides the type your action receives**:
|
|
132
|
+
|
|
133
|
+
| uiType | For | Your handler receives |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `textInput` | short free text | a string |
|
|
136
|
+
| `textArea` | prose the admin writes — a note, a message body | a string |
|
|
137
|
+
| `dropdown` | a choice; needs an options source | the chosen option's `id`, a string |
|
|
138
|
+
| `numberInput` | `fieldType: "number"` fields — **requires** it | a JSON **number**, not a numeric string |
|
|
139
|
+
| `dateInput` | `isDate: true` fields — **requires** it | an **RFC 3339 timestamp** string, e.g. `"2026-10-21T00:00:00.000Z"` |
|
|
140
|
+
|
|
141
|
+
`numberInput` on a non-number field, and `dateInput` on a field without `isDate: true`, both fail the publish. Declaring `textInput` on a number or date field is still accepted but gives the admin a bare text box and hands you whatever they typed — prefer the specific widget.
|
|
142
|
+
- `output.type` is required on every api: `object`, `list`, `delete`, or `void`. `input` is required for POST/PUT/PATCH.
|
|
143
|
+
- **`apis[].description` is the action's visible label** in the automations UI. Write a 3-4 word verb phrase, max 40 characters — the publish gate rejects longer.
|
|
144
|
+
- ❌ `"Create a new feedback item, e.g. from an intake form"` — over 40 chars, reads like documentation.
|
|
145
|
+
- ✅ `"Log a time entry"` — what the admin sees in the action picker.
|
|
146
|
+
|
|
147
|
+
## Recipient ids — exact names or automations can't route
|
|
148
|
+
|
|
149
|
+
Automations read recipient ids from event payloads and action inputs by **exact camelCase name**: `clientId`, `companyId`, `internalUserId`. There is no fuzzy matching and lookups are case-sensitive.
|
|
150
|
+
|
|
151
|
+
- ❌ `"fieldName": "assigneeInternalUserId"` — never picked up; "message the assignee" automations silently get nobody.
|
|
152
|
+
- ✅ `"fieldName": "internalUserId"` — declare the semantic role in `label` ("Assignee") instead of the field name.
|
|
153
|
+
- Any resource with a `trigger` must carry at least one of the three in its `schema` (declare it `optional: true` if not every event instance has one).
|
|
154
|
+
- A recipient `dropdownSource` must pair its `inputType` with the canonical field: `client`/`allClients` → `clientId`, `company`/`allCompanies` → `companyId`, `internalUser`/`allInternalUsers` → `internalUserId`.
|
|
155
|
+
|
|
156
|
+
## Triggers
|
|
157
|
+
|
|
158
|
+
- **A bare trigger (no `configuration`) is the common case** — it fires on every event of its type. Only add `configuration` when there's a meaningful payload field for a workspace admin to filter on ("only this project"); never add one just because triggers can have one.
|
|
159
|
+
- When present, `configuration` holds **one** field (the builder renders one filter per trigger): `id`, `label`, `fieldProperty` (must exactly match a `schema[].fieldName` — filters read the payload at that key), `fieldType` (the public type enum), `type: "dropdown"` (the only widget the trigger UI renders), `fieldOperator: "equals"`, and exactly one options source (`options` or `dynamicOptions`; `nameField` isn't needed here). `addAnyOption: true` adds an "Any" choice that disables filtering.
|
|
160
|
+
- `snapshotCxt` lists payload fields shown on the automation Run details page: `{label, property}`, where `property` must exactly match a `schema[].fieldName`.
|
|
161
|
+
- **Always set `trigger.icon`** — pick the name that best matches the event's domain from the list below. The name must be from this exact set (case-sensitive) or the publish fails; when nothing fits, use `Automation`.
|
|
162
|
+
|
|
163
|
+
<!-- BEGIN trigger-icon-list (every name must be in the platform's design-system icon set; pinned by a unit test) -->
|
|
164
|
+
```text
|
|
165
|
+
Automation Play Pause Repeat Time AlarmClock Stopwatch Timer HourglassHalf Calendar CalendarDays
|
|
166
|
+
Message Messages MessageDots Comment CommentDots Reply Send Email EmailUnread Inbox InboxFull Notification
|
|
167
|
+
Form Contract Note Book Newspaper File Files FileLines FolderOpen FolderMove Archive Unarchive Attachment Link
|
|
168
|
+
Download Upload Export Image Images Camera Video Film
|
|
169
|
+
Profile Teams IdBadge Building Buildings Home Briefcase Lead
|
|
170
|
+
Invoice InvoicePaid Billing Dollar MoneyBills Coins Wallet Bank Subscription PlansPayments Calculator
|
|
171
|
+
Store ShoppingBag CartShopping Tag Tags Ticket Gift
|
|
172
|
+
Tasks Subtask Checklist List NumberedList ToDo InProgress New Check Success Warning Failed Flag
|
|
173
|
+
Star Medal Trophy AwardSimple Bookmark Pin Filter Search Eye Edit Trash
|
|
174
|
+
Key Unlock Authentication Shield ShieldCheck Fingerprint
|
|
175
|
+
Location LocationArrow Map Compass Plane Car Bus Train Ship Rocket
|
|
176
|
+
Chart GraphBarSolid Table Gauge ArrowTrendUp ArrowTrendDown WavePulse Bullhorn Marketing
|
|
177
|
+
Code CodeBranch API Bug Wrench Hammer ScrewdriverWrench Lightbulb AI WandMagicSparkles
|
|
178
|
+
HeartPulse Heart Bandage BriefcaseMedical Flask GraduationCap Dumbbell
|
|
179
|
+
Mug ForkKnife Music Headphones Palette Leaf Cloud Umbrella Droplet
|
|
180
|
+
Laptop Desktop Mobile Print Qrcode BarcodeRead Language EarthAmericas
|
|
181
|
+
```
|
|
182
|
+
<!-- END trigger-icon-list -->
|
|
183
|
+
|
|
184
|
+
## Emitting events at runtime
|
|
185
|
+
|
|
186
|
+
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:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
await assembly.sendWebhook('timeEntry.created', {
|
|
190
|
+
id: entry.id,
|
|
191
|
+
clientId: entry.clientId,
|
|
192
|
+
companyId: entry.companyId,
|
|
193
|
+
minutes: entry.minutes,
|
|
194
|
+
startedAt: entry.startedAt.toISOString(),
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
- `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.
|
|
199
|
+
- Call it **after** the database write commits, never before — an event emitted for a rolled-back write is a phantom trigger.
|
|
200
|
+
- 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.
|
|
201
|
+
- Emission must never break the user's action: wrap the call in try/catch, log the failure, and continue.
|
|
202
|
+
- **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.
|
|
203
|
+
|
|
204
|
+
### Reporting who acted
|
|
205
|
+
|
|
206
|
+
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:
|
|
207
|
+
|
|
208
|
+
| Header | Value |
|
|
209
|
+
|---|---|
|
|
210
|
+
| `X-Assembly-Actor-Id` | the viewer's id from `requireAuth` — `internalUserId`, else `clientId` |
|
|
211
|
+
| `X-Assembly-Source` | `web` when a person did this in the app's UI |
|
|
212
|
+
| `X-Assembly-Client-IP` | the request's `x-forwarded-for` |
|
|
213
|
+
| `X-Assembly-User-Agent` | the request's `user-agent` |
|
|
214
|
+
|
|
215
|
+
`sendWebhook` cannot carry headers, so a user-triggered event is emitted through `assembly.fetch`, which adds the app's auth for you:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
const viewer = await requireAuth(req);
|
|
219
|
+
if (viewer instanceof NextResponse) return viewer;
|
|
220
|
+
// … the write commits here …
|
|
221
|
+
|
|
222
|
+
await assembly.fetch('/v1/webhooks/timeEntry.created', {
|
|
223
|
+
method: 'POST',
|
|
224
|
+
headers: {
|
|
225
|
+
'X-Assembly-Actor-Id': viewer.internalUserId ?? viewer.clientId ?? '',
|
|
226
|
+
'X-Assembly-Source': 'web',
|
|
227
|
+
'X-Assembly-Client-IP': req.headers.get('x-forwarded-for') ?? '',
|
|
228
|
+
'X-Assembly-User-Agent': req.headers.get('user-agent') ?? '',
|
|
229
|
+
},
|
|
230
|
+
body: JSON.stringify({ id: entry.id, clientId: entry.clientId, minutes: entry.minutes }),
|
|
231
|
+
});
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
- **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.
|
|
235
|
+
- **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.
|
|
236
|
+
- A background job has no caller — `getOfflineAssembly()` plus plain `sendWebhook(...)` is right there, and the entry reads as a system event.
|
|
237
|
+
|
|
238
|
+
## Serving actions
|
|
239
|
+
|
|
240
|
+
**Every api with `exposeToAutomations: true` requires two things in the app: `lib/assembly-actions.ts` exactly as written below, and a route serving it at exactly that record's `baseUrl`.** This is not optional infrastructure. Skipping the wrapper and hand-rolling the route is the single most damaging mistake in this file: the platform's call carries no cookie and no viewer bearer, so nothing about it *looks* unauthenticated, and a route that forgets to verify the token becomes a public endpoint that mutates workspace data under the app's own privileges. Serving it at the wrong path is silent too — the automation just fails with an opaque error and the handler never runs.
|
|
241
|
+
|
|
242
|
+
The platform runs the action by calling the record's `baseUrl`, and `requireAuth` cannot authenticate that call. Copy this file in verbatim, then reuse it for every action route in the app:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
// lib/assembly-actions.ts
|
|
246
|
+
import { assemblyApi, type AssemblyAPI } from '@assembly-js/node-sdk';
|
|
247
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
248
|
+
|
|
249
|
+
// The only statuses whose response body the platform reads back.
|
|
250
|
+
const SURFACED_STATUSES = [400, 401, 404, 422, 429, 500] as const;
|
|
251
|
+
export type ActionErrorStatus = (typeof SURFACED_STATUSES)[number];
|
|
252
|
+
|
|
253
|
+
export class ActionError extends Error {
|
|
254
|
+
readonly status: ActionErrorStatus;
|
|
255
|
+
constructor(message: string, status: ActionErrorStatus = 400) {
|
|
256
|
+
super(message);
|
|
257
|
+
this.name = 'ActionError';
|
|
258
|
+
this.status = status;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface ActionContext {
|
|
263
|
+
workspaceId: string;
|
|
264
|
+
internalUserId?: string;
|
|
265
|
+
assembly: AssemblyAPI;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function automationActionRoute<TInput>(
|
|
269
|
+
handler: (input: TInput, context: ActionContext) => Promise<unknown>,
|
|
270
|
+
): (request: NextRequest) => Promise<NextResponse> {
|
|
271
|
+
return async (request) => {
|
|
272
|
+
// The platform sends the app token as a query param; the header is the
|
|
273
|
+
// transport the public API moves to. Never a cookie or a viewer bearer.
|
|
274
|
+
const token =
|
|
275
|
+
request.nextUrl.searchParams.get('token') ??
|
|
276
|
+
request.headers.get('x-assembly-token');
|
|
277
|
+
// The proxy middleware exchanges that single-use token before this handler
|
|
278
|
+
// runs and forwards the minted bearer on this header (inbound copies are
|
|
279
|
+
// stripped, so it is always the middleware's own). By handler time the raw
|
|
280
|
+
// token is spent — platform calls must ride the bearer whenever one is
|
|
281
|
+
// present; the token path below serves exchange-disabled apps.
|
|
282
|
+
const bearer = request.headers.get('x-assembly-request-bearer');
|
|
283
|
+
if (!token && !bearer) {
|
|
284
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
let assembly: AssemblyAPI;
|
|
288
|
+
let workspaceId: string | undefined;
|
|
289
|
+
let internalUserId: string | undefined;
|
|
290
|
+
try {
|
|
291
|
+
// With a bearer, identity comes from its platform-verified claims. On
|
|
292
|
+
// the token path, the app API key derives the key the token was
|
|
293
|
+
// encrypted with; the SDK swallows a decrypt failure and leaves the
|
|
294
|
+
// payload unset — an absent workspaceId is how a forged or stale
|
|
295
|
+
// credential surfaces either way.
|
|
296
|
+
assembly = await assemblyApi({
|
|
297
|
+
apiKey: process.env.ASSEMBLY_API_KEY ?? '',
|
|
298
|
+
...(bearer ? { bearer } : { token: token ?? undefined }),
|
|
299
|
+
baseUrl: process.env.ASSEMBLY_API_BASE_URL || undefined,
|
|
300
|
+
});
|
|
301
|
+
const payload = await assembly.getTokenPayload?.();
|
|
302
|
+
workspaceId = payload?.workspaceId;
|
|
303
|
+
internalUserId = payload?.internalUserId;
|
|
304
|
+
} catch {
|
|
305
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
306
|
+
}
|
|
307
|
+
if (!workspaceId) {
|
|
308
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let input: TInput;
|
|
312
|
+
try {
|
|
313
|
+
const raw = await request.text();
|
|
314
|
+
input = (raw.trim() === '' ? {} : JSON.parse(raw)) as TInput;
|
|
315
|
+
} catch {
|
|
316
|
+
return NextResponse.json(
|
|
317
|
+
{ error: 'Request body is not valid JSON' },
|
|
318
|
+
{ status: 400 },
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
const result = await handler(input, {
|
|
324
|
+
workspaceId,
|
|
325
|
+
// The token names the actor only when the platform minted it with one;
|
|
326
|
+
// an automation always names them on the query string.
|
|
327
|
+
internalUserId:
|
|
328
|
+
internalUserId ||
|
|
329
|
+
request.nextUrl.searchParams.get('userId') ||
|
|
330
|
+
undefined,
|
|
331
|
+
assembly,
|
|
332
|
+
});
|
|
333
|
+
// A handler that returns a NextResponse (anything out of
|
|
334
|
+
// lib/api-errors.ts) already carries its own status — pass it straight
|
|
335
|
+
// through, or JSON-wrapping it would serialize the response object into
|
|
336
|
+
// a 200 body and the automation would record a success.
|
|
337
|
+
if (result instanceof NextResponse) return result;
|
|
338
|
+
// Otherwise always a JSON body: the platform parses the response even
|
|
339
|
+
// for a void operation, so a 204 or an empty body fails the run step.
|
|
340
|
+
return NextResponse.json(result ?? {});
|
|
341
|
+
} catch (err) {
|
|
342
|
+
if (err instanceof ActionError) {
|
|
343
|
+
return NextResponse.json({ error: err.message }, { status: err.status });
|
|
344
|
+
}
|
|
345
|
+
// Never return err.message from a catch — driver and Postgres strings
|
|
346
|
+
// leak to the caller. Same rule as lib/api-errors.ts.
|
|
347
|
+
console.error(err);
|
|
348
|
+
return NextResponse.json({ error: 'Something went wrong' }, { status: 500 });
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
Each action route is then just the handler, served at the record's `baseUrl`:
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
// app/api/assembly/time-entries/route.ts — the record's baseUrl
|
|
358
|
+
import { automationActionRoute, ActionError } from '@assembly-js/app-foundation/server-actions';
|
|
359
|
+
import { db } from '@/lib/db';
|
|
360
|
+
import { timeEntries } from '@/lib/schema';
|
|
361
|
+
|
|
362
|
+
export const POST = automationActionRoute<{
|
|
363
|
+
minutes: number;
|
|
364
|
+
clientId?: string;
|
|
365
|
+
note?: string;
|
|
366
|
+
}>(async (input, { workspaceId, internalUserId }) => {
|
|
367
|
+
if (typeof input.minutes !== 'number' || input.minutes <= 0) {
|
|
368
|
+
throw new ActionError('Minutes must be a positive number', 400);
|
|
369
|
+
}
|
|
370
|
+
const [entry] = await db
|
|
371
|
+
.insert(timeEntries)
|
|
372
|
+
.values({ ...input, workspaceId, loggedBy: internalUserId })
|
|
373
|
+
.returning();
|
|
374
|
+
if (!entry) throw new ActionError('Could not log the time entry', 500);
|
|
375
|
+
return entry;
|
|
376
|
+
});
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
- **The route file's path must equal the record's `baseUrl`.** `baseUrl: "/api/assembly/projects"` means `app/api/assembly/projects/route.ts` and nothing else — the platform calls `baseUrl` verbatim, so a route at `app/api/automation/create-project/route.ts` is never reached and the step dies on the HTML 404 Next returns.
|
|
380
|
+
- ❌ **Never authenticate an action route with `getOfflineAssembly()`.** It mints an app-scoped bearer without reading the request, so the route accepts *any* anonymous POST and performs it with the app's own privileges — and it only refuses when a *viewer* is present, which a platform call and an attacker both lack:
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
// ❌ an unauthenticated public mutation endpoint
|
|
384
|
+
export async function POST(request: NextRequest) {
|
|
385
|
+
const { clientId } = await request.json();
|
|
386
|
+
const assembly = await getOfflineAssembly();
|
|
387
|
+
// …creates records for anyone who knows this URL…
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
✅ `automationActionRoute` verifies the `?token=` before the handler runs, and the `assembly` client it passes you already acts as the user the automation runs on behalf of.
|
|
392
|
+
- **Validate the input in the handler.** The platform does not check an action's request body against `input.schema` before forwarding it, and a workspace admin can leave a config field holding something unusable, so treat everything in `input` as untrusted. Reject with `throw new ActionError(...)` — that is the idiom here. Returning `badRequest(...)` also works (the wrapper passes a `NextResponse` through untouched), but throwing keeps the rejection on one line and gives TypeScript the narrowing that a `return` in a `void`-typed branch does not.
|
|
393
|
+
- **Automations never append `/:id` and never forward query params** other than `token` and `userId`. An operation exposed to automations must take everything it needs from `input.schema` — a by-id action reached from an automation gets only its body. (The public API path does append `/:id`; that's what a `baseUrl`/`[id]` route serves.)
|
|
394
|
+
- **Always return a value.** An empty body or a `204` fails the run step even for `output.type: "void"`; the wrapper sends `{}` when the handler returns nothing. What you return becomes the step's output on the Run details page, keyed by `resourceName`.
|
|
395
|
+
- `throw new ActionError(message, status)` to fail a step deliberately, using one of the six statuses above — the platform reads the body on no others. The message reaches the app's logs and public-API callers, but the automation Run page shows only "Unable to \<step label\>", so don't lean on it to explain a failure to a workspace admin. Any other thrown error becomes a generic 500 with no details, by design.
|
|
396
|
+
- ❌ Don't wrap an action route in `requireAuth`, and don't call `getAssembly()` — both look for a cookie or a viewer bearer that a platform call does not carry, so the route would 401 every time. ✅ Use the `assembly` client the wrapper builds; it acts as the user the automation runs on behalf of.
|
|
397
|
+
|
|
398
|
+
## When a trigger or action doesn't work
|
|
399
|
+
|
|
400
|
+
| Symptom | Cause | Fix |
|
|
401
|
+
| --- | --- | --- |
|
|
402
|
+
| `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 |
|
|
403
|
+
| `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` |
|
|
404
|
+
| 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` |
|
|
405
|
+
| 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` |
|
|
406
|
+
| Action returns 500 "Action is not registered" | `automationActionRoute`'s first argument doesn't match any `apis[].name` | Copy the name from this file verbatim |
|
|
407
|
+
| Handler auth passes but its platform-api calls 401 | The `?token=` is single-use and the proxy middleware already exchanged it | The wrapper's bearer path covers this — copy the current `lib/assembly-actions.ts` verbatim |
|
|
408
|
+
| The trigger or action never appears in the builder | The manifest hasn't been published, or the workspace doesn't have this app installed | Publish; confirm the app is installed in that workspace |
|
|
409
|
+
| A `dynamicOptions` dropdown renders empty | The options api sits on a record whose `baseUrl` doesn't serve it, or whose `schema[]` requires fields the response omits | Give the options endpoint its own `id`+`name` record — see "Serving dynamic options" |
|
|
410
|
+
|
|
411
|
+
Verify the whole path before calling the work done: publish, open the workspace's automations builder, build one automation on this app's trigger and one on its action, run them, and read the Run details page. When a step fails, `/tmp/dev-server.log` carries the app-side error the Run page omits.
|
|
412
|
+
|
|
413
|
+
## Serving dynamic options
|
|
414
|
+
|
|
415
|
+
A `dynamicOptions` dropdown is fetched through the same proxy pipeline as every other api call, and two record-level rules decide whether it works. Both failures are invisible in the builder — the dropdown just renders empty — so the structure must be right up front:
|
|
416
|
+
|
|
417
|
+
1. **Every api in a record is served at that record's single `baseUrl`.** Registering the options GET on a business record sends the platform's fetch to that record's route, where nothing serves a GET, and the fetch dies as a 404 the builder never shows.
|
|
418
|
+
2. **The platform validates every returned list item against the owning record's `schema[]`** — each required field must be present on each item. An endpoint that correctly returns `{ "data": [ { "id": ..., "name": ... } ] }` under a record whose schema requires anything more is rejected after the fetch succeeds.
|
|
419
|
+
|
|
420
|
+
**A dynamic-options endpoint must therefore live in its own record**: `baseUrl` pointing at the route that serves it, a `schema` of exactly `id` and `name`, and one GET api with `output.type: "list"` — the `Projects` record in the example above is this shape. Pointing `dynamicOptions.requestUrl` at a business record's real list api instead is valid only when that route returns full records satisfying the record's schema *and* those records carry a `name` — the dropdown reads `.data[].id` / `.data[].name` off whatever comes back.
|
|
421
|
+
|
|
422
|
+
Serve the route with the same wrapper as actions — its token check is what authenticates the platform's call, and a GET's empty body parses to `{}`:
|
|
423
|
+
|
|
424
|
+
```ts
|
|
425
|
+
// app/api/assembly/projects/route.ts — the options record's baseUrl
|
|
426
|
+
import { automationActionRoute } from '@assembly-js/app-foundation/server-actions';
|
|
427
|
+
import { db } from '@/lib/db';
|
|
428
|
+
import { projects } from '@/lib/schema';
|
|
429
|
+
import { eq } from 'drizzle-orm';
|
|
430
|
+
|
|
431
|
+
export const GET = automationActionRoute(async (_input, { workspaceId }) => {
|
|
432
|
+
const rows = await db
|
|
433
|
+
.select({ id: projects.id, name: projects.name })
|
|
434
|
+
.from(projects)
|
|
435
|
+
.where(eq(projects.workspaceId, workspaceId));
|
|
436
|
+
return { data: rows };
|
|
437
|
+
});
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
## Rules the publish gate enforces
|
|
441
|
+
|
|
442
|
+
- **Event names are `{resourceName}.{verb}`** — `timeEntry.created`, never a bare `created` and never a native platform event name (`task.completed`, `client.created`, …). Unique within the app, case-insensitively. Both segments are single alphanumeric camelCase tokens — no underscores, hyphens, or extra dots: `intake.reviewStatusChanged`, never `intake.review_status_changed`. The publish gate auto-normalizes non-conforming names to camelCase, so the event name your runtime code emits must be the canonical form — write camelCase everywhere.
|
|
443
|
+
- **One action = one record + method.** `exposeToAutomations: true` requires an `input` with at least one field where `automationSettings.isEnabled` is true. `(method, path)` pairs must be unique across the whole file.
|
|
444
|
+
- **The zero-value rule**: the platform treats a zero value (`0`, `""`, `false`) on a required field as missing. Declare booleans and legitimately-zeroable numbers `optional: true`; a required `bool` field is rejected outright.
|
|
445
|
+
- **`dynamicOptions`** on action fields (`{ "requestUrl": "/projects", "nameField": "name" }`) points a dropdown at a **GET api registered by any record in this manifest** — cross-resource references are normal (a `feedbackItem` input listing categories points at the `category` record's GET api). The `requestUrl` must equal that api's `path`; `/:id` forms don't qualify. The endpoint must return `{ "data": [ { "id": "...", "name": "..." } ] }` — `nameField` may be nested/composite (`name.first|name.last`). The referenced api must live in a record whose `baseUrl` actually serves it and whose `schema[]` the response satisfies — see "Serving dynamic options". Use `dropdownSource` instead when the choice is an Assembly client/company/internal user.
|
|
446
|
+
- Paths are lowercase `[a-z0-9-_]` segments with an optional trailing `/:id`, and must not contain `tasks`, `task-templates`, or `comments` — those route to the platform's Tasks app.
|
|
447
|
+
- **`apis[].description` max 40 characters** (all apis). It becomes the action's label — see above.
|
|
448
|
+
- **`trigger.icon`, when set, must be one of the names in the icon list above.** Unknown names fail the publish; an omitted icon renders a generic default.
|
|
449
|
+
- **Renaming breaks saved automations.** An action's identity derives from the api's `name` and a trigger's from `eventName`; renaming either orphans every automation a workspace built on it. Pick names as if they're permanent.
|
|
450
|
+
|
|
451
|
+
## Validation
|
|
452
|
+
|
|
453
|
+
**Run `bun run validate:manifest` after every edit to this file, and never finish the task on a failing run.** It applies the same rules as the publish gate and names the exact field and problem; skip it and the first signal is a failed publish the user has to bring back to you. `bun run build` runs it first for the same reason.
|
|
454
|
+
|
|
455
|
+
The publish gate re-validates and fails the publish with the specific problems — fix the file and publish again. It stays authoritative: a repo scaffolded before this feature carries a `.assembly/validate-manifest.mjs` that doesn't know this file, so a clean local run is necessary but not sufficient. If yours doesn't recognize `apiResources`, follow the rules above exactly.
|
|
456
|
+
<!-- /feature:api-resources -->
|