@assembly-js/app-foundation 0.0.10 → 0.0.12-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +156 -594
- package/bin/validate-manifest.mjs +28 -11
- package/package.json +1 -1
- package/skills/assembly-app-bridge-header/SKILL.md +15 -1
- package/skills/assembly-automations/SKILL.md +9 -8
- package/skills/assembly-avatar-sub/SKILL.md +1 -1
- package/skills/assembly-clients-companies/SKILL.md +1 -1
- package/skills/assembly-custom-fields/SKILL.md +4 -0
- package/skills/assembly-integrations/SKILL.md +1 -1
- package/skills/assembly-internal-users/SKILL.md +1 -1
- package/skills/assembly-native-resources/SKILL.md +1 -1
- package/skills/assembly-notifications/SKILL.md +2 -2
- package/skills/assembly-platform-mcp/SKILL.md +1 -1
- package/skills/assembly-route-errors/SKILL.md +1 -1
- package/skills/assembly-session-token/SKILL.md +5 -4
- package/skills/assembly-storage/SKILL.md +1 -1
- package/skills/clearing-client-notifications/SKILL.md +1 -1
|
@@ -310,7 +310,8 @@ const MAX_ACTION_DESC_LENGTH = 40;
|
|
|
310
310
|
const MAX_PATH_LENGTH = 512;
|
|
311
311
|
|
|
312
312
|
const RESOURCE_NAME_RE = /^[a-z][a-zA-Z0-9]{1,63}$/;
|
|
313
|
-
const EVENT_NAME_RE = /^[a-zA-Z0-9]+\.[a-zA-Z0-9]+$/;
|
|
313
|
+
const EVENT_NAME_RE = /^(~app-[a-z0-9]+\.)?[a-zA-Z0-9]+\.[a-zA-Z0-9]+$/;
|
|
314
|
+
const APP_EVENT_MARKER_RE = /^~app-[a-z0-9]+\./;
|
|
314
315
|
const API_PATH_RE = /^(\/[a-z0-9\-_]+)+(\/:id)?$/;
|
|
315
316
|
|
|
316
317
|
const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
@@ -530,22 +531,33 @@ function camelCaseAlnum(s) {
|
|
|
530
531
|
return out;
|
|
531
532
|
}
|
|
532
533
|
|
|
534
|
+
// Mirrors marketplace_application.PeelAppEventMarker: splits a possibly-namespaced
|
|
535
|
+
// event name into its "~<appId>." marker (empty when absent) and the bare
|
|
536
|
+
// {resourceName}.{verb} remainder.
|
|
537
|
+
function peelAppEventMarker(eventName) {
|
|
538
|
+
const m = APP_EVENT_MARKER_RE.exec(eventName);
|
|
539
|
+
const marker = m ? m[0] : '';
|
|
540
|
+
return { marker, bare: eventName.slice(marker.length) };
|
|
541
|
+
}
|
|
542
|
+
|
|
533
543
|
// Mirrors normalizeAppApiEventName in app_api_resources.go: the publish gate
|
|
534
544
|
// coerces an authored eventName toward the canonical {resourceName}.{verb}
|
|
535
545
|
// form instead of rejecting it ("intake.review_status_changed" →
|
|
536
546
|
// "intake.reviewStatusChanged"; a bare verb gains the record's resourceName;
|
|
537
|
-
// an empty verb becomes "triggered").
|
|
538
|
-
//
|
|
547
|
+
// an empty verb becomes "triggered"). Any leading "~<appId>." marker is peeled
|
|
548
|
+
// off, normalized around, and re-attached verbatim. Returns the input unchanged
|
|
549
|
+
// when nothing salvageable remains, so the format error still fires.
|
|
539
550
|
function normalizeEventName(resourceName, eventName) {
|
|
551
|
+
const { marker, bare } = peelAppEventMarker(eventName);
|
|
540
552
|
let prefix = resourceName;
|
|
541
|
-
let verb =
|
|
542
|
-
const dot =
|
|
553
|
+
let verb = bare;
|
|
554
|
+
const dot = bare.indexOf('.');
|
|
543
555
|
if (dot >= 0) {
|
|
544
|
-
prefix = camelCaseAlnum(
|
|
545
|
-
verb =
|
|
556
|
+
prefix = camelCaseAlnum(bare.slice(0, dot)) || resourceName;
|
|
557
|
+
verb = bare.slice(dot + 1);
|
|
546
558
|
}
|
|
547
559
|
verb = camelCaseAlnum(verb) || 'triggered';
|
|
548
|
-
const normalized = prefix + '.' + verb;
|
|
560
|
+
const normalized = marker + prefix + '.' + verb;
|
|
549
561
|
return EVENT_NAME_RE.test(normalized) ? normalized : eventName;
|
|
550
562
|
}
|
|
551
563
|
|
|
@@ -1025,16 +1037,21 @@ if (existsSync(API_RESOURCES)) {
|
|
|
1025
1037
|
if (!EVENT_NAME_RE.test(eventName)) {
|
|
1026
1038
|
apiErrors.push(webhookWhere + ': eventName "' + eventName + '" must be {resourceName}.{verb}.');
|
|
1027
1039
|
} else {
|
|
1028
|
-
|
|
1040
|
+
// A namespaced app event carries a "~<appId>." marker that is opaque
|
|
1041
|
+
// to the resourceName and native-collision checks; validate the bare
|
|
1042
|
+
// {resourceName}.{verb} remainder.
|
|
1043
|
+
const bare = peelAppEventMarker(eventName).bare;
|
|
1044
|
+
const prefix = bare.slice(0, bare.indexOf('.'));
|
|
1029
1045
|
if (!resourceNames.has(prefix)) {
|
|
1030
1046
|
apiErrors.push(webhookWhere + ': eventName "' + eventName + '" must be prefixed by one of the manifest\'s resourceNames.');
|
|
1031
1047
|
}
|
|
1032
|
-
const
|
|
1033
|
-
if (NATIVE_EVENTS_LOWER.has(
|
|
1048
|
+
const lowerBare = bare.toLowerCase();
|
|
1049
|
+
if (NATIVE_EVENTS_LOWER.has(lowerBare)) {
|
|
1034
1050
|
apiErrors.push(webhookWhere + ': eventName "' + eventName + '" is a native platform event name.');
|
|
1035
1051
|
} else if (NATIVE_NAMESPACES_LOWER.has(prefix.toLowerCase())) {
|
|
1036
1052
|
apiErrors.push(webhookWhere + ': eventName "' + eventName + '" uses the native "' + prefix + '" event namespace; pick a distinct resourceName.');
|
|
1037
1053
|
}
|
|
1054
|
+
const lowerEventName = eventName.toLowerCase();
|
|
1038
1055
|
if (seenEventNames.has(lowerEventName)) {
|
|
1039
1056
|
apiErrors.push(webhookWhere + ': eventName "' + eventName + '" is already declared by ' + seenEventNames.get(lowerEventName) + ' (event names are matched case-insensitively).');
|
|
1040
1057
|
} else {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: assembly-app-bridge-header
|
|
3
|
-
description: Use any time the app sets breadcrumbs, primary
|
|
3
|
+
description: Use any time the app sets breadcrumbs, primary/secondary CTA, or actions menu in the header chrome. Always drive it via useAssemblyHeader — never call AssemblyBridge.header.set* from your own useEffect. The parent auto-prepends the app name as breadcrumb 0 — never include it yourself.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
The Assembly header (breadcrumbs, primary/secondary CTA buttons, overflow actions menu) is controlled from your app. **Drive it with the `useAssemblyHeader` hook** — it ships in the foundation at `app/components/use-assembly-header.ts`.
|
|
@@ -70,6 +70,20 @@ useAssemblyHeader({ onRootClick: goHome, breadcrumbs: [{ label: 'Reports' }] });
|
|
|
70
70
|
|
|
71
71
|
Use breadcrumbs only for hierarchical navigation, not branding. A flat one-page app omits `breadcrumbs` (or passes `[]`). A multi-level app uses one entry per level: `[{ label: 'Reports' }, { label: 'Q4 2026' }]`. A breadcrumb's `onClick` makes it clickable; omit it for the current/leaf level.
|
|
72
72
|
|
|
73
|
+
## Loading state on a `primaryCta` — no `disabled`/`loading` prop
|
|
74
|
+
|
|
75
|
+
Header CTAs have no `disabled` or `loading` prop. Indicate progress by changing the `primaryCta` value itself — swap the label and replace `onClick` with a no-op while the action runs, then restore both when it completes:
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
useAssemblyHeader({
|
|
79
|
+
primaryCta: isSaving
|
|
80
|
+
? { label: 'Saving…', icon: 'Check', onClick: () => {} }
|
|
81
|
+
: { label: 'Save', icon: 'Check', onClick: save },
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Because the hook re-applies whatever value you pass on every render, this is just a state change — no imperative set/clear needed. Don't let the user click a button and see nothing happen.
|
|
86
|
+
|
|
73
87
|
## Icon and color constraints
|
|
74
88
|
|
|
75
89
|
- **`icon` must be a `CtaIcon`** — the only valid values are `'Archive' | 'Plus' | 'Templates' | 'Trash' | 'Check'`. Any other string (e.g. `'Download'`, `'Settings'`, `'Edit'`) is a type error — omit the icon (it's optional) when none fit.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<!-- feature:api-resources -->
|
|
2
2
|
---
|
|
3
3
|
name: assembly-automations
|
|
4
|
-
description:
|
|
4
|
+
description: REQUIRED when a request should react to an in-app event AND write to the Assembly platform (message a client, set a CRM field, send a contract/form, share a file) — not just the app's tables. Covers api-resources.json schema, event naming, triggers/actions, webhooks, automationActionRoute.
|
|
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.
|
|
@@ -63,7 +63,7 @@ The entire file is one top-level key. Unknown keys anywhere are publish errors,
|
|
|
63
63
|
],
|
|
64
64
|
"apis": [
|
|
65
65
|
{
|
|
66
|
-
"name": "Create time entry",
|
|
66
|
+
"name": "~app-43574726.Create time entry",
|
|
67
67
|
"description": "Log a time entry",
|
|
68
68
|
"method": "POST",
|
|
69
69
|
"path": "/time-entries",
|
|
@@ -84,7 +84,7 @@ The entire file is one top-level key. Unknown keys anywhere are publish errors,
|
|
|
84
84
|
],
|
|
85
85
|
"webhooks": [
|
|
86
86
|
{
|
|
87
|
-
"eventName": "timeEntry.created",
|
|
87
|
+
"eventName": "~app-43574726.timeEntry.created",
|
|
88
88
|
"eventDescription": "Fires when a time entry is logged",
|
|
89
89
|
"trigger": {
|
|
90
90
|
"name": "Time entry logged",
|
|
@@ -94,7 +94,7 @@ The entire file is one top-level key. Unknown keys anywhere are publish errors,
|
|
|
94
94
|
}
|
|
95
95
|
},
|
|
96
96
|
{
|
|
97
|
-
"eventName": "timeEntry.approved",
|
|
97
|
+
"eventName": "~app-43574726.timeEntry.approved",
|
|
98
98
|
"eventDescription": "Fires when a time entry is approved",
|
|
99
99
|
"trigger": {
|
|
100
100
|
"name": "Time entry approved",
|
|
@@ -223,7 +223,7 @@ const userAgent = req.headers.get('user-agent') ?? '';
|
|
|
223
223
|
|
|
224
224
|
after(async () => {
|
|
225
225
|
try {
|
|
226
|
-
const res = await assembly.fetch('/v1/webhooks
|
|
226
|
+
const res = await assembly.fetch('/v1/webhooks/~app-43574726.timeEntry.created', {
|
|
227
227
|
method: 'POST',
|
|
228
228
|
headers: {
|
|
229
229
|
'X-Assembly-Actor-Id': actorId,
|
|
@@ -242,7 +242,7 @@ after(async () => {
|
|
|
242
242
|
});
|
|
243
243
|
```
|
|
244
244
|
|
|
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.
|
|
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, `~<appId>.` prefix and all (the `~` is URL-safe, so the path needs no encoding); the platform validates the name and payload against this manifest's registration, then runs matching automations.
|
|
246
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
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
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.
|
|
@@ -471,14 +471,15 @@ export const GET = automationActionRoute(async (_input, { workspaceId }) => {
|
|
|
471
471
|
|
|
472
472
|
## Rules the publish gate enforces
|
|
473
473
|
|
|
474
|
-
- **
|
|
474
|
+
- **Namespace every event name and exposed action with your app id.** Prefix each `eventName`, and the `name` of every api you set `exposeToAutomations: true` on, with `~$ASSEMBLY_APP_ID.` — read the id from the `ASSEMBLY_APP_ID` environment variable (`echo $ASSEMBLY_APP_ID`, it looks like `app-43574726`) and write the literal value in. This is what keeps your triggers and actions from colliding with another app installed in the same workspace, and the prefix is stripped before the user ever sees it. Prefix only **new** events/actions — never re-prefix or rename one you already shipped (see the renaming rule below).
|
|
475
|
+
- **Event names are `~<appId>.{resourceName}.{verb}`** — e.g. `~app-43574726.timeEntry.created`. The `{resourceName}.{verb}` remainder is still exactly two single alphanumeric camelCase tokens: no bare `created`, no native platform event name (`task.completed`, `client.created`, …), and no underscores, hyphens, or extra dots inside the remainder — `~app-43574726.intake.reviewStatusChanged`, never `intake.review_status_changed`. The publish gate normalizes only the remainder to camelCase and carries the `~<appId>.` prefix through untouched, so the event name your runtime code emits must be byte-identical to what you declare — write it the same in both places.
|
|
475
476
|
- **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.
|
|
476
477
|
- **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.
|
|
477
478
|
- **`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.
|
|
478
479
|
- 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.
|
|
479
480
|
- **`apis[].description` max 40 characters** (all apis). It becomes the action's label — see above.
|
|
480
481
|
- **`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.
|
|
481
|
-
- **Renaming breaks saved automations.** An action's identity derives from the api's `name` and a trigger's from `eventName
|
|
482
|
+
- **Renaming breaks saved automations.** An action's identity derives from the api's `name` and a trigger's from `eventName` — including the `~<appId>.` prefix, which is part of that identity. Renaming either, or adding/removing the prefix on one you already shipped, orphans every automation a workspace built on it. Pick names as if they're permanent.
|
|
482
483
|
|
|
483
484
|
## Telling the user
|
|
484
485
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: assembly-avatar-sub
|
|
3
|
-
description: Use whenever a client's name or avatar
|
|
3
|
+
description: Use whenever a client's name or avatar renders in a list, table, card, or detail header — including tables of app records that reference a client. Clients belong to many companies, so an avatar without its company is ambiguous. Covers AvatarSub + expandClientsByCompany row-duplication.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
`AvatarSub` renders a single nested sub-avatar overlaid at the bottom-right of the parent avatar. It always contains exactly one `Avatar`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: assembly-clients-companies
|
|
3
|
-
description: Use when an app stores or scopes data by client or company — record keys, scope filters, company selectors, CRM-tab scoping. Clients belong to MANY companies (
|
|
3
|
+
description: Use when an app stores or scopes data by client or company — record keys, scope filters, company selectors, CRM-tab scoping. Clients belong to MANY companies (companyIds[]); a client viewer carries both clientId and active companyId. client.companyId is deprecated.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Clients and companies are **many-to-many**: a client belongs to many companies via `client.companyIds: string[]`. In the portal, a client can switch between their companies — the viewer's `companyId` always reflects whichever company they currently have active. Apps that ignore this model show incomplete company pickers, or key and filter records on one dimension and then leak a company-A record into the same client's company-B view. This skill covers how to scope, key, and filter records correctly.
|
|
@@ -69,6 +69,10 @@ await assembly.updateClient({
|
|
|
69
69
|
|
|
70
70
|
Send the structured object — a bare string is accepted for backwards compatibility with older data, but it collapses to a single `fullAddress` string and loses city/region/postal code/country as separate values. Every property is optional except that the field is only considered set if `country` or `fullAddress` is non-empty; `fullAddress` is derived automatically from the other components if you omit it.
|
|
71
71
|
|
|
72
|
+
## Every other field type
|
|
73
|
+
|
|
74
|
+
Non-option, non-address fields take a direct string value — stringify numbers and booleans yourself: `{ mrr: '5000' }`, not `{ mrr: 5000 }`.
|
|
75
|
+
|
|
72
76
|
## Symptoms when it goes wrong
|
|
73
77
|
|
|
74
78
|
| Mistake | What you'll see |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: assembly-integrations
|
|
3
|
-
description: Use BEFORE declaring requiredIntegrations in .assembly/app.json
|
|
3
|
+
description: Use BEFORE declaring requiredIntegrations in .assembly/app.json or calling getIntegration() from @assembly-js/app-foundation/server (Slack + other providers). Covers manifest declaration, the broker client, per-provider gotchas (pagination, IDs vs names, membership), ConnectorError handling.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
## How it works
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: assembly-internal-users
|
|
3
|
-
description: Use when the app
|
|
3
|
+
description: Use when the app reads/lists internal users, or an internal viewer browses clients/companies in the Dashboard. Covers Client Access (isClientAccessLimited, companyAccessList) scoping. Internal users sign into the Dashboard only. Detect via tokenPayload.internalUserId.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Internal users are the **business team** running the Assembly workspace. They sign into the Dashboard at the workspace's domain, never the branded Client Portal. They have full read/write power over clients, companies, files, etc. — **within their Client Access scope** (see below).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: assembly-native-resources
|
|
3
|
-
description: Use any time the app references a native Assembly message channel, form, or portal file/file
|
|
3
|
+
description: Use any time the app references a native Assembly message channel, form, or portal file/file-channel. Never hardcode the ID — discover it at runtime; IDs are workspace-scoped. Not assembly-forms (validation) or assembly-storage (app-private uploads).
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
## The non-negotiable
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: assembly-notifications
|
|
3
3
|
# <!-- !feature:notification-settings -->
|
|
4
|
-
description: Use when the app
|
|
4
|
+
description: Use when the app must notify internal users or clients about something that happened — in-product, email, or both. Covers createNotification, the Notification Center /n surface, and clearing the client badge (see clearing-client-notifications).
|
|
5
5
|
# <!-- /feature:notification-settings -->
|
|
6
6
|
# <!-- feature:notification-settings -->
|
|
7
|
-
description: Use when the app
|
|
7
|
+
description: Use when the app must notify internal users or clients, or needs per-user notification preferences (Product/Email toggles). Covers createNotification, the /n surface, notificationSettings + notificationSettingId, and clearing the client badge (see clearing-client-notifications).
|
|
8
8
|
# <!-- /feature:notification-settings -->
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<!-- feature:platform-mcp -->
|
|
2
2
|
---
|
|
3
3
|
name: assembly-platform-mcp
|
|
4
|
-
description: Use when the app
|
|
4
|
+
description: Use when the app depends on missing workspace config — e.g. no Phone-type client custom field exists yet. Provision it at build time via the scoped platform MCP server (mcp__platform__). Workspace config only — never client-facing data (no clients, messages, notifications, invoices).
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
## Provisioning custom fields at build time
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: assembly-route-errors
|
|
3
|
-
description:
|
|
3
|
+
description: REQUIRED in every app/api/**/route.ts handler. Return { error } JSON with the right status; never return err.message — Postgres/driver strings leak. Use handleRouteError/badRequest/notFound/internalError from @assembly-js/app-foundation/server. Validate before hitting DB columns (e.g. parseUuid).
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Route handlers must return safe, user-facing error messages. A `catch` that does `NextResponse.json({ error: err.message }, { status: 500 })` leaks raw Postgres errors (`invalid input syntax for type uuid: "…"`) and other driver internals to the client.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: assembly-session-token
|
|
3
|
-
description: Use any time
|
|
3
|
+
description: Use any time the app reads identity, fetches data, or touches auth/token handling. Covers the bearer lifecycle (session token, bearer cookie, getAssembly(), silent re-exchange), getSessionIdentity/useAssembly, useServerAction, authFetch. See assembly-route-auth for requireAuth/requireCompanyScope.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Auth is **cookie-borne and automatic** — you never handle the session token and you never pass a token to anything. On the first load only, the parent opens your app at `…/?token=<session-token>`. The foundation middleware (`proxy.ts`) exchanges that session token exactly once (server-side, via `POST /v1/session`) for a short-lived **user-scoped bearer** and stores it in an HttpOnly, `SameSite=None` cookie. That mint is the only time the session token is ever used. Every server call your app makes reads the bearer from that cookie — `getAssembly()` returns an authenticated client with no token argument. The bearer lasts 60 minutes; when it expires, `authFetch` and `useServerAction` silently re-exchange (mint a fresh bearer from the parent's current session token via `/api/assembly-reexchange`) and retry — no reload, no code from you.
|
|
@@ -130,17 +130,18 @@ Everything above is **request-scoped**: a user is in the loop and `getAssembly()
|
|
|
130
130
|
|
|
131
131
|
```ts
|
|
132
132
|
// app/api/cron/sync/route.ts — no viewer, no cookie
|
|
133
|
-
import { getOfflineAssembly } from '@assembly-js/app-foundation/server';
|
|
133
|
+
import { getOfflineAssembly, listAll } from '@assembly-js/app-foundation/server';
|
|
134
134
|
|
|
135
135
|
export async function GET() {
|
|
136
136
|
const assembly = await getOfflineAssembly();
|
|
137
|
-
|
|
137
|
+
// Background work must cover everyone — walk every page, not just the first.
|
|
138
|
+
const clients = await listAll((nextToken) => assembly.listClients({ nextToken, limit: 100 }));
|
|
138
139
|
// …background work…
|
|
139
140
|
return Response.json({ ok: true });
|
|
140
141
|
}
|
|
141
142
|
```
|
|
142
143
|
|
|
143
|
-
It **throws** (not returns null) if `ASSEMBLY_API_KEY` is missing or the mint fails — there's no client to re-exchange in the background, so let it propagate. `getSessionIdentity()` returns `null` on this path (the bearer is the app, not a person). Only reach for this outside a request; ignore it for normal UI work.
|
|
144
|
+
It **throws** (not returns null) if `ASSEMBLY_API_KEY` is missing or the mint fails — there's no client to re-exchange in the background, so let it propagate. `getSessionIdentity()` returns `null` on this path (the bearer is the app, not a person). Only reach for this outside a request — using it inside a user request bypasses that viewer's own scope; ignore it for normal UI work.
|
|
144
145
|
|
|
145
146
|
## What NOT to do
|
|
146
147
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: assembly-storage
|
|
3
|
-
description:
|
|
3
|
+
description: Use when an app uploads/downloads its own private files (attachments, exports) via pre-signed S3 URLs — the client PUTs bytes directly to S3, never proxied through an API route. NOT for files the user wants in the portal Files tab (use the Files module / createFile).
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
## How it works
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: clearing-client-notifications
|
|
3
|
-
description: REQUIRED whenever
|
|
3
|
+
description: REQUIRED whenever createNotification is called with recipientClientId. Use when building a client-facing view showing notified content, or a client action completing what a notification prompted — so the sidebar badge clears. Covers clear-on-view vs clear-on-action via clearClientNotifications.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
## Why this exists
|