@assembly-js/app-foundation 0.0.35 → 0.0.37-rc.0
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 +9 -6
- package/bin/validate-manifest.mjs +8 -4
- package/dist/api-errors.js +5 -0
- package/dist/assembly.d.ts +13 -0
- package/dist/assembly.js +123 -57
- package/dist/auth.js +24 -14
- package/dist/replace-rows.d.ts +27 -0
- package/dist/replace-rows.js +51 -0
- package/dist/resource-resolution.d.ts +1 -0
- package/dist/resource-resolution.js +3 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +2 -0
- package/dist/session-exchange.js +10 -13
- package/dist/verify-bearer.d.ts +29 -0
- package/dist/verify-bearer.js +170 -0
- package/package.json +4 -2
- package/skills/assembly-automations/SKILL.md +1 -1
- package/skills/assembly-clients-companies/SKILL.md +1 -1
- package/skills/assembly-notifications/SKILL.md +19 -0
- package/skills/assembly-record-writes/SKILL.md +50 -15
- package/skills/assembly-route-auth/SKILL.md +1 -1
- package/skills/assembly-scheduled-work/SKILL.md +2 -0
- package/skills/assembly-session-token/SKILL.md +1 -1
package/CLAUDE.md
CHANGED
|
@@ -198,6 +198,8 @@ Relationships:
|
|
|
198
198
|
|
|
199
199
|
**Listing companies for users.** `listCompanies` excludes **placeholder** companies (nameless solo-client stand-ins) by default for app-builder apps, so company dropdowns, tables, and filters won't show blank "ghost" rows — you don't need to filter them yourself. To *include* placeholders (e.g. to surface solo clients), pass `listCompanies({ isPlaceholder: true })` explicitly. See `assembly-companies` skill.
|
|
200
200
|
|
|
201
|
+
**App isolation.** `listAppInstalls`/`retrieveAppInstall` tell you which apps are installed (id, appId, type, icon, and other install metadata), and `listAppConnections`/`createAppConnection` manage manual embed and link connections between installed apps — that's real, supported SDK surface, and requests to inspect an install or set up one of those connections are not out of bounds. None of it reaches another app's own data, though: you have no access to another installed app's database, tables, or any other internal data, and there is no SDK call that reads or writes another app's data store. If a request needs another app's private data, say only that it isn't possible and stop — no alternatives and no follow-up question. In user-facing chat, call them apps, not custom apps. Do not propose a bridge: no endpoint or API on the other app, no webhook, push, or sync, and nothing the user has to paste between chats. Do not volunteer automations, clients, companies, custom fields, or an Assembly module as a substitute. If the user explicitly asks to call an existing registered API exposed by another app, that is supported API access rather than database access; never suggest it unprompted. Likewise, if the request itself explicitly asks for automations, CRM data, or an installed module's supported data (for example invoices), that is a separate supported request. Never reach another app's database directly.
|
|
202
|
+
|
|
201
203
|
---
|
|
202
204
|
|
|
203
205
|
## Iframe constraints — read before writing UI
|
|
@@ -252,7 +254,7 @@ Two things flow into the running app from Assembly:
|
|
|
252
254
|
| | Where | Visibility |
|
|
253
255
|
|---|---|---|
|
|
254
256
|
| `ASSEMBLY_API_KEY` | `process.env.ASSEMBLY_API_KEY` | **Server only.** Never expose. Identifies the *app*, not the user. Used by the foundation to mint bearers — you don't touch it. |
|
|
255
|
-
| User bearer | HttpOnly cookie, set by the middleware after the one-time `?token=` exchange | **You never handle it.** `getAssembly()` reads it to act as the current user; `getSessionIdentity()`
|
|
257
|
+
| User bearer | HttpOnly cookie, set by the middleware after the one-time `?token=` exchange | **You never handle it.** `getAssembly()` reads it to act as the current user; `getSessionIdentity()` returns the viewer the platform verified for it. See "How auth works". |
|
|
256
258
|
|
|
257
259
|
---
|
|
258
260
|
|
|
@@ -645,11 +647,11 @@ const assembly = await getAssembly(); // no token — the bearer comes from
|
|
|
645
647
|
const items = await assembly.listItems({});
|
|
646
648
|
```
|
|
647
649
|
|
|
648
|
-
`getAssembly()` is async — always `await` it. If the bearer is missing or expired it throws `TokenInvalidError`; let it propagate, and the client helpers (`useServerAction` / `authFetch`) re-exchange and retry for you.
|
|
650
|
+
`getAssembly()` is async — always `await` it. If the bearer is missing or expired it throws `TokenInvalidError`; let it propagate, and the client helpers (`useServerAction` / `authFetch`) re-exchange and retry for you. If the platform cannot be reached to verify the bearer it throws `AuthUnavailableError` (and `getSessionIdentity()` does too) — a different error on purpose: it is not an expiry, so never catch it into `null`/signed-out and never re-exchange on it; let a route return 503, and render a retry state ("Couldn't load, try again" with a retry button) wherever a server component reads identity.
|
|
649
651
|
|
|
650
652
|
#### Identity — who is the viewer?
|
|
651
653
|
|
|
652
|
-
Server-side, `getSessionIdentity()` returns the viewer
|
|
654
|
+
Server-side, `getSessionIdentity()` returns the viewer the platform verified for the bearer (no token to pass; the check is cached briefly per server instance):
|
|
653
655
|
|
|
654
656
|
```ts
|
|
655
657
|
import { getSessionIdentity } from '@assembly-js/app-foundation/server';
|
|
@@ -798,7 +800,7 @@ Two pre-built helpers guard API routes that operate on company-scoped resources:
|
|
|
798
800
|
import { requireAuth, requireCompanyScope } from '@assembly-js/app-foundation/server';
|
|
799
801
|
```
|
|
800
802
|
|
|
801
|
-
**`requireAuth(req)`**
|
|
803
|
+
**`requireAuth(req)`** verifies the bearer with the platform and returns the identity from the platform's verification response. Returns a 401 `NextResponse` with a `{ code: 'token_expired' }` body **and** `x-assembly-token-expired: 1` header when the bearer is missing, expired or not accepted — `authFetch` detects this and re-exchanges + retries automatically. When the platform cannot be reached to verify, it returns a 503 with `{ code: 'auth_unavailable' }` and **no** expiry marker, so nothing re-exchanges; the 503 reaches the app, which should render a retry state or let the user retry.
|
|
802
804
|
|
|
803
805
|
**`requireCompanyScope(identity, resource, assembly?)`** asserts the viewer can access a resource scoped to a company **or to a client**. Internal users always pass (client-vs-staff isolation only — not Client Access; filter app-owned data for limited internal users per `assembly-internal-users`). For a company-scoped resource, client access is checked against `identity.companyId`; when that is absent (a multi-company client not loaded in a CRM tab), pass the bearer client from `getAssemblyForRequest` as the third argument and the helper fetches the client's full `companyIds` list to verify membership. For a **client-scoped resource** (`companyId` null), pass the resource's `clientId` too — `{ companyId: item.companyId, clientId: item.clientId }` — so the owning client passes; without it every client gets a 404, including the one the record is assigned to. Returns `null` on success, or a 403/404 `NextResponse` on mismatch. A 403 from `requireCompanyScope` is a permanent authorization failure — a re-exchange will not fix cross-tenant access. When a row carries **both** scope columns the helper requires both to match, so a client-owned row is never reachable by a sibling client in the same company — see `assembly-route-auth`.
|
|
804
806
|
|
|
@@ -868,7 +870,7 @@ So there is nothing to refresh, subscribe to, or thread. Don't touch `AssemblyBr
|
|
|
868
870
|
|
|
869
871
|
Use identity to branch your UI *before* making data calls — don't infer the viewer by catching a 403 (slow, and any unrelated 403 misclassifies).
|
|
870
872
|
|
|
871
|
-
Server-side (`getSessionIdentity()`
|
|
873
|
+
Server-side (`getSessionIdentity()` returns the platform-verified viewer; no token to pass):
|
|
872
874
|
|
|
873
875
|
```ts
|
|
874
876
|
import { getSessionIdentity } from '@assembly-js/app-foundation/server';
|
|
@@ -1158,7 +1160,7 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
|
|
|
1158
1160
|
- ❌ Don't make Assembly API calls from client components without a server-side proxy. The apiKey can't leave the server.
|
|
1159
1161
|
- ❌ Don't reinvent Clients, Companies, or custom fields — they are always available via the SDK. For Messages, Files, Forms, Tasks, Billing, Contracts, Notes, or Knowledge Base: confirm the module is installed (`listAppInstalls`) first; if it is, call the SDK methods listed above rather than rebuilding it. If it is not installed, do not suggest it or plan around it.
|
|
1160
1162
|
- ❌ Don't use `assembly.clients.list()` — the SDK is flat. Use `assembly.listClients(...)`.
|
|
1161
|
-
- ❌ Don't cache
|
|
1163
|
+
- ❌ Don't cache a viewer's identity yourself. The foundation already caches the platform's verification for up to 60 seconds per server instance; a longer cache of your own can outlive a session change (a shared session across tabs can switch viewers).
|
|
1162
1164
|
<!-- !feature:app-owned-header -->
|
|
1163
1165
|
- ❌ Don't render your own breadcrumbs / titles in the DOM — use `AssemblyBridge.header.*`.
|
|
1164
1166
|
- ❌ Don't render primary page-level actions (Submit, Save, Edit, Create) as `<Button>` elements inside the page body. These MUST go in the header via the `useAssemblyHeader` hook's `primaryCta`. Contextual inline actions (e.g. row-level edit icons, list "Add item" buttons) may stay in-page. In-page primary CTAs make the UX inconsistent with the rest of the portal. **Exception: the notification surface (`/n`).** The Notification Center renders `/n` in a headerless iframe — `useAssemblyHeader` is ignored. On this surface, primary actions MUST be inline `<Button>` elements.
|
|
@@ -1179,6 +1181,7 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
|
|
|
1179
1181
|
- ❌ Don't hardcode mutation error messages like "Failed to save preference". Read `error` from the response body and surface it to the user.
|
|
1180
1182
|
- ❌ Don't `throw new Error` with a sentence the user should read from a server action. Production redacts that throw, so the toast shows a generic React error and the response is HTTP 500. Return `{ ok: false, message }` and toast `message`. Let `TokenInvalidError` and unexpected failures throw. See `assembly-action-results`.
|
|
1181
1183
|
- ❌ Don't `Promise.all` a list-sized write fan-out (one `createNotification` / `markNotificationRead` / update per recipient or record). Writes cap at 5 concurrent per token; firing a whole list 429s and silently drops the overflow. Use `sendThrottled` from `@assembly-js/app-foundation/server`. A fixed handful of calls is the only exception. See "Write rate limit" above.
|
|
1184
|
+
- ❌ Don't re-notify a still-true condition from a cron. Record the alert on the subject before the send and clear it when the condition ends. See "Recurring conditions" in `assembly-notifications`.
|
|
1182
1185
|
- ❌ Don't use title case — see "Labels, copy, and dropdowns" above.
|
|
1183
1186
|
- ❌ Don't render raw IDs or pass a raw ID string as `value` on `ComboboxItem` — see "Select and Combobox — base-ui differences" above.
|
|
1184
1187
|
- ❌ Don't write a scalar string to a custom field that has options. **All option-bearing custom fields are `multiSelect` in Assembly's schema** — even ones that LOOK like single-select dropdowns. The value must always be an array of option keys: `{ favorite_pizza: ['pepperoni'] }`, never `{ favorite_pizza: 'pepperoni' }`. A scalar string for a multiSelect causes a 400 with `invalid custom field option <value> provided`.
|
|
@@ -18,8 +18,11 @@ const MAX_NOTIFICATION_SETTINGS = 50;
|
|
|
18
18
|
// Setting ids are permanent: they key each internal user's saved toggles and
|
|
19
19
|
// are passed as notificationSettingId on createNotification, so the validator
|
|
20
20
|
// requires stable author-chosen ids instead of letting the platform generate
|
|
21
|
-
// ones that would rotate on every deploy.
|
|
22
|
-
|
|
21
|
+
// ones that would rotate on every deploy. Hyphens and underscores are both
|
|
22
|
+
// stable (a UUID is hyphenated and already matches). Rejecting underscores
|
|
23
|
+
// failed the build when the user or the plan named a category that way
|
|
24
|
+
// (past_due). This check does not rewrite ids already saved on the app.
|
|
25
|
+
const NOTIFICATION_SETTING_ID = /^[a-z0-9]+([_-][a-z0-9]+)*$/;
|
|
23
26
|
|
|
24
27
|
function fail(lines) {
|
|
25
28
|
console.error('');
|
|
@@ -166,10 +169,11 @@ if (doc.notificationSettings !== undefined) {
|
|
|
166
169
|
errors.push(at + ' must be an object with "id", "label", and "surfaces".');
|
|
167
170
|
return;
|
|
168
171
|
}
|
|
169
|
-
if (typeof setting.id !== 'string' || !
|
|
172
|
+
if (typeof setting.id !== 'string' || !NOTIFICATION_SETTING_ID.test(setting.id)) {
|
|
170
173
|
errors.push(
|
|
171
174
|
at +
|
|
172
|
-
'.id is required and must be
|
|
175
|
+
'.id is required and must be lowercase letters, digits, hyphens, or underscores ' +
|
|
176
|
+
'(e.g. "task-completed" or "past_due"). Ids are permanent — ' +
|
|
173
177
|
"they key each internal user's saved toggles and are passed as notificationSettingId " +
|
|
174
178
|
'when creating notifications.',
|
|
175
179
|
);
|
package/dist/api-errors.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
|
+
import { AuthUnavailableError, authUnavailableResponse } from './assembly.js';
|
|
2
3
|
export function badRequest(message) {
|
|
3
4
|
return NextResponse.json({ error: message }, { status: 400 });
|
|
4
5
|
}
|
|
@@ -26,6 +27,10 @@ function isInvalidInputDbError(err) {
|
|
|
26
27
|
// to the UI when handlers do that.
|
|
27
28
|
export function handleRouteError(err, fallback = 'Something went wrong') {
|
|
28
29
|
console.error(err);
|
|
30
|
+
// A platform outage is not an app bug; surface it as a retryable 503.
|
|
31
|
+
if (err instanceof AuthUnavailableError) {
|
|
32
|
+
return authUnavailableResponse();
|
|
33
|
+
}
|
|
29
34
|
if (isInvalidInputDbError(err)) {
|
|
30
35
|
return badRequest('Invalid request');
|
|
31
36
|
}
|
package/dist/assembly.d.ts
CHANGED
|
@@ -19,6 +19,17 @@ export declare class TokenInvalidError extends Error {
|
|
|
19
19
|
readonly code = "TOKEN_INVALID";
|
|
20
20
|
constructor(message?: string);
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Thrown when the platform could not be reached to verify the viewer's bearer
|
|
24
|
+
* (outage, throttle, timeout). It says nothing about the bearer, so it is NOT a
|
|
25
|
+
* token-invalid error: do not re-exchange on it (that would spend a single-use
|
|
26
|
+
* session token for nothing) and do not render a signed-out state. Render a
|
|
27
|
+
* retry state instead. Distinct `name`/`code` from TokenInvalidError on purpose.
|
|
28
|
+
*/
|
|
29
|
+
export declare class AuthUnavailableError extends Error {
|
|
30
|
+
readonly code = "AUTH_UNAVAILABLE";
|
|
31
|
+
constructor(message?: string);
|
|
32
|
+
}
|
|
22
33
|
export declare function getAssembly(input?: AssemblyInput): Promise<AssemblyAPI>;
|
|
23
34
|
/**
|
|
24
35
|
* Runs `task` after the response is sent (Next's `after()`), with a viewer-
|
|
@@ -48,6 +59,8 @@ export declare function getAssembly(input?: AssemblyInput): Promise<AssemblyAPI>
|
|
|
48
59
|
export declare function afterAssembly(task: (assembly: AssemblyAPI) => Promise<unknown>, options?: {
|
|
49
60
|
token?: string;
|
|
50
61
|
}): void;
|
|
62
|
+
export declare const AUTH_UNAVAILABLE_CODE = "auth_unavailable";
|
|
63
|
+
export declare function authUnavailableResponse(): NextResponse;
|
|
51
64
|
/**
|
|
52
65
|
* OFFLINE / BACKGROUND ONLY — cron jobs, webhook handlers, and any other code
|
|
53
66
|
* path where there is NO user in the loop and thus no session token or bearer
|
package/dist/assembly.js
CHANGED
|
@@ -3,33 +3,55 @@ import { cookies, headers } from 'next/headers';
|
|
|
3
3
|
import { after, NextResponse } from 'next/server';
|
|
4
4
|
import { BEARER_COOKIE_NAME, BEARER_HEADER_NAME, decodeBearer, isSessionExchangeEnabled, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
|
|
5
5
|
import { mintBearerFromAppKey } from './mint-bearer.js';
|
|
6
|
+
import { verifyBearer } from './verify-bearer.js';
|
|
6
7
|
// Trusted API origin for this stack, supplied by the build environment — NOT by
|
|
7
8
|
// a token claim. Empty in prod/shared-staging (the SDK's env default is already
|
|
8
9
|
// correct); set to a per-dev private stack's platform-api URL otherwise. Passed
|
|
9
10
|
// to the SDK as `baseUrl` so tokenless (cookie-only) calls reach the right stack.
|
|
10
11
|
const trustedBaseUrl = process.env.ASSEMBLY_API_BASE_URL || undefined;
|
|
11
|
-
// Reads the viewer bearer for the current request, independent of the
|
|
12
|
-
// flag
|
|
13
|
-
// minted it; the Set-Cookie isn't yet visible), then the HttpOnly cookie on
|
|
14
|
-
// later request. Returns
|
|
15
|
-
// prerender) — not an error.
|
|
16
|
-
|
|
17
|
-
async function readViewerBearer() {
|
|
12
|
+
// Reads the viewer bearer candidates for the current request, independent of the
|
|
13
|
+
// exchange flag: first the header the proxy forwards on first load (the exchange
|
|
14
|
+
// just minted it; the Set-Cookie isn't yet visible), then the HttpOnly cookie on
|
|
15
|
+
// every later request. Returns [] outside a request scope (e.g. build-time
|
|
16
|
+
// prerender) — not an error.
|
|
17
|
+
async function readBearerCandidates() {
|
|
18
18
|
try {
|
|
19
19
|
const fromProxy = (await headers()).get(BEARER_HEADER_NAME);
|
|
20
|
-
if (fromProxy && decodeBearer(fromProxy))
|
|
21
|
-
return fromProxy;
|
|
22
20
|
const fromCookie = (await cookies()).get(BEARER_COOKIE_NAME)?.value;
|
|
23
|
-
|
|
24
|
-
return fromCookie;
|
|
21
|
+
return [fromProxy, fromCookie].filter((b) => Boolean(b));
|
|
25
22
|
}
|
|
26
23
|
catch {
|
|
27
|
-
return
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// First candidate that survives the no-network precheck, unverified. For callers
|
|
28
|
+
// that only need to carry the credential across a boundary (afterAssembly) and
|
|
29
|
+
// verify it where it is used.
|
|
30
|
+
async function readViewerBearer() {
|
|
31
|
+
return (await readBearerCandidates()).find((b) => decodeBearer(b));
|
|
32
|
+
}
|
|
33
|
+
// The viewer's bearer together with the identity the platform verified for it.
|
|
34
|
+
// A rejected candidate falls through to the next one, as an undecodable one
|
|
35
|
+
// always did. An unverifiable one stops the walk: it says nothing about the
|
|
36
|
+
// bearer, so the request must neither be treated as signed out nor re-exchanged.
|
|
37
|
+
async function readVerifiedViewer() {
|
|
38
|
+
for (const bearer of await readBearerCandidates()) {
|
|
39
|
+
const result = await verifyBearer(bearer);
|
|
40
|
+
if (result.ok)
|
|
41
|
+
return { bearer, identity: result.identity };
|
|
42
|
+
if (result.reason === 'unavailable')
|
|
43
|
+
throw new AuthUnavailableError();
|
|
28
44
|
}
|
|
29
45
|
return undefined;
|
|
30
46
|
}
|
|
31
|
-
//
|
|
32
|
-
// on (the legacy path carries identity in proxy headers, not a bearer cookie).
|
|
47
|
+
// Verified viewer for request-scoped auth: only consulted when the exchange flow
|
|
48
|
+
// is on (the legacy path carries identity in proxy headers, not a bearer cookie).
|
|
49
|
+
async function resolveRequestViewer() {
|
|
50
|
+
if (!isSessionExchangeEnabled())
|
|
51
|
+
return undefined;
|
|
52
|
+
return readVerifiedViewer();
|
|
53
|
+
}
|
|
54
|
+
// Unverified bearer for deferred work; see readViewerBearer.
|
|
33
55
|
async function resolveRequestBearer() {
|
|
34
56
|
if (!isSessionExchangeEnabled())
|
|
35
57
|
return undefined;
|
|
@@ -64,15 +86,22 @@ async function hasViewerRequest() {
|
|
|
64
86
|
return false;
|
|
65
87
|
}
|
|
66
88
|
}
|
|
67
|
-
// Server-only: the current user's identity,
|
|
68
|
-
// proxy-forwarded header on first load, else the HttpOnly cookie).
|
|
69
|
-
// the app learns who the viewer is — workspace, client/company,
|
|
70
|
-
// without ever handling the raw session token. Used to seed the
|
|
71
|
-
// and by server components. Returns null when unauthenticated or
|
|
72
|
-
// exchange is disabled (the legacy path carries identity in proxy
|
|
89
|
+
// Server-only: the current user's identity, verified by the platform for the
|
|
90
|
+
// bearer (the proxy-forwarded header on first load, else the HttpOnly cookie).
|
|
91
|
+
// This is how the app learns who the viewer is — workspace, client/company,
|
|
92
|
+
// internal user — without ever handling the raw session token. Used to seed the
|
|
93
|
+
// client provider and by server components. Returns null when unauthenticated or
|
|
94
|
+
// when the exchange is disabled (the legacy path carries identity in proxy
|
|
95
|
+
// headers). Throws AuthUnavailableError when the platform could not be reached
|
|
96
|
+
// to verify, so a layout can render a retry state rather than a signed-out one.
|
|
73
97
|
export async function getSessionIdentity() {
|
|
74
|
-
const
|
|
75
|
-
|
|
98
|
+
const viewer = await resolveRequestViewer();
|
|
99
|
+
if (!viewer)
|
|
100
|
+
return null;
|
|
101
|
+
// Project to the documented shape so layouts that pass the whole object to a
|
|
102
|
+
// client provider don't serialize server-side fields to the browser.
|
|
103
|
+
const { workspaceId, clientId, internalUserId, companyId, notificationId, instanceId, sessionId } = viewer.identity;
|
|
104
|
+
return { workspaceId, clientId, internalUserId, companyId, notificationId, instanceId, sessionId };
|
|
76
105
|
}
|
|
77
106
|
/**
|
|
78
107
|
* Thrown when a session token is required but missing, or was supplied but
|
|
@@ -91,6 +120,20 @@ export class TokenInvalidError extends Error {
|
|
|
91
120
|
this.name = 'TokenInvalidError';
|
|
92
121
|
}
|
|
93
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Thrown when the platform could not be reached to verify the viewer's bearer
|
|
125
|
+
* (outage, throttle, timeout). It says nothing about the bearer, so it is NOT a
|
|
126
|
+
* token-invalid error: do not re-exchange on it (that would spend a single-use
|
|
127
|
+
* session token for nothing) and do not render a signed-out state. Render a
|
|
128
|
+
* retry state instead. Distinct `name`/`code` from TokenInvalidError on purpose.
|
|
129
|
+
*/
|
|
130
|
+
export class AuthUnavailableError extends Error {
|
|
131
|
+
code = 'AUTH_UNAVAILABLE';
|
|
132
|
+
constructor(message = 'Assembly could not verify the session right now') {
|
|
133
|
+
super(message);
|
|
134
|
+
this.name = 'AuthUnavailableError';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
94
137
|
export async function getAssembly(input) {
|
|
95
138
|
const token = typeof input === 'string' ? input : input?.token;
|
|
96
139
|
// Exchange enabled ⇒ this is a bearer-capable app, and it authenticates via
|
|
@@ -104,31 +147,25 @@ export async function getAssembly(input) {
|
|
|
104
147
|
// after() and pass it through explicitly as `input.token`; this honors that
|
|
105
148
|
// pre-resolved bearer instead of re-deriving it from request scope. The
|
|
106
149
|
// common case (no explicit token) is unchanged: derive from the request.
|
|
150
|
+
// Either way the identity is the one the platform verified for the bearer.
|
|
107
151
|
if (isSessionExchangeEnabled()) {
|
|
108
|
-
|
|
109
|
-
if (
|
|
110
|
-
|
|
152
|
+
let viewer;
|
|
153
|
+
if (token) {
|
|
154
|
+
const result = await verifyBearer(token);
|
|
155
|
+
if (result.ok) {
|
|
156
|
+
viewer = { bearer: token, identity: result.identity };
|
|
157
|
+
}
|
|
158
|
+
else if (result.reason === 'unavailable') {
|
|
159
|
+
throw new AuthUnavailableError();
|
|
160
|
+
}
|
|
111
161
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (!payload?.workspaceId) {
|
|
115
|
-
throw new TokenInvalidError();
|
|
162
|
+
else {
|
|
163
|
+
viewer = await resolveRequestViewer();
|
|
116
164
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
// documented contract (payload.notificationId marks the Notification
|
|
120
|
-
// Center surface) intact for exchange-capable apps. Becomes a harmless
|
|
121
|
-
// no-op once the SDK maps these claims natively.
|
|
122
|
-
const session = decodeBearer(bearer);
|
|
123
|
-
if (session?.notificationId || session?.instanceId) {
|
|
124
|
-
const merged = {
|
|
125
|
-
...payload,
|
|
126
|
-
notificationId: session.notificationId,
|
|
127
|
-
instanceId: session.instanceId,
|
|
128
|
-
};
|
|
129
|
-
assembly.getTokenPayload = () => Promise.resolve(merged);
|
|
165
|
+
if (!viewer) {
|
|
166
|
+
throw new TokenInvalidError(token ? undefined : 'Assembly bearer is required');
|
|
130
167
|
}
|
|
131
|
-
return
|
|
168
|
+
return getBearerAssembly(viewer.bearer, viewer.identity);
|
|
132
169
|
}
|
|
133
170
|
// Exchange disabled (legacy apps built before this feature, or a rollback):
|
|
134
171
|
// the raw session token is the credential and is sent as X-API-Key, exactly
|
|
@@ -198,13 +235,35 @@ export function afterAssembly(task, options) {
|
|
|
198
235
|
function tokenExpiredResponse() {
|
|
199
236
|
return NextResponse.json({ error: 'Unauthorized', code: 'token_expired' }, { status: 401, headers: { [TOKEN_EXPIRED_HEADER]: '1' } });
|
|
200
237
|
}
|
|
238
|
+
export const AUTH_UNAVAILABLE_CODE = 'auth_unavailable';
|
|
239
|
+
// 503 with deliberately NO token-expired marker: the bearer was never judged, so
|
|
240
|
+
// authFetch must not re-exchange (which would spend a single-use session token)
|
|
241
|
+
// and the client should retry the same request instead.
|
|
242
|
+
export function authUnavailableResponse() {
|
|
243
|
+
return NextResponse.json({ error: 'Service unavailable', code: AUTH_UNAVAILABLE_CODE }, { status: 503 });
|
|
244
|
+
}
|
|
201
245
|
// Builds a bearer-only SDK client. The SDK's native `bearer` option sends
|
|
202
246
|
// Authorization: Bearer on every request — generated service methods and fetch
|
|
203
|
-
// alike — and, with no apiKey supplied, sends NO X-API-Key.
|
|
204
|
-
// the
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
247
|
+
// alike — and, with no apiKey supplied, sends NO X-API-Key. The API origin comes
|
|
248
|
+
// from the trusted build-env baseUrl, never a token/bearer claim. When the
|
|
249
|
+
// caller has a verified identity, getTokenPayload() answers with it rather than
|
|
250
|
+
// with the SDK's read of the bearer's claims.
|
|
251
|
+
async function getBearerAssembly(bearer, identity) {
|
|
252
|
+
const assembly = await assemblyApi({ bearer, baseUrl: trustedBaseUrl });
|
|
253
|
+
if (identity) {
|
|
254
|
+
// Includes the embed-context ids the SDK's bearer-mode payload omits, so
|
|
255
|
+
// payload.notificationId still marks the Notification Center surface.
|
|
256
|
+
const payload = {
|
|
257
|
+
workspaceId: identity.workspaceId,
|
|
258
|
+
clientId: identity.clientId,
|
|
259
|
+
companyId: identity.companyId,
|
|
260
|
+
internalUserId: identity.internalUserId,
|
|
261
|
+
notificationId: identity.notificationId,
|
|
262
|
+
instanceId: identity.instanceId,
|
|
263
|
+
};
|
|
264
|
+
assembly.getTokenPayload = () => Promise.resolve(payload);
|
|
265
|
+
}
|
|
266
|
+
return assembly;
|
|
208
267
|
}
|
|
209
268
|
/**
|
|
210
269
|
* OFFLINE / BACKGROUND ONLY — cron jobs, webhook handlers, and any other code
|
|
@@ -250,23 +309,30 @@ export async function getOfflineAssembly() {
|
|
|
250
309
|
}
|
|
251
310
|
// Returns a request-scoped SDK client authenticated for the current user.
|
|
252
311
|
//
|
|
253
|
-
// Exchange enabled: bearer-only. A
|
|
254
|
-
// a client that carries Authorization: Bearer on every request; a
|
|
255
|
-
//
|
|
256
|
-
// the x-assembly-token-expired convention
|
|
312
|
+
// Exchange enabled: bearer-only. A bearer the platform verifies (proxy header or
|
|
313
|
+
// cookie) yields a client that carries Authorization: Bearer on every request; a
|
|
314
|
+
// missing or rejected bearer yields a 401 NextResponse so the client re-exchanges
|
|
315
|
+
// via the x-assembly-token-expired convention, and a bearer that could not be
|
|
316
|
+
// verified yields a 503 without that marker. There is no session-token fallback.
|
|
257
317
|
// Exchange disabled: the legacy session-token (X-API-Key) path.
|
|
258
318
|
export async function getAssemblyForRequest(req) {
|
|
259
319
|
if (isSessionExchangeEnabled()) {
|
|
260
320
|
// Bearer-only: the proxy-forwarded header on first load, else the HttpOnly
|
|
261
|
-
// cookie.
|
|
262
|
-
//
|
|
263
|
-
//
|
|
321
|
+
// cookie. No bearer (or a rejected/expired one) ⇒ 401 marker so the client
|
|
322
|
+
// re-exchanges; there is no X-API-Key / session-token fallback for a
|
|
323
|
+
// bearer-capable app.
|
|
264
324
|
const bearer = req.headers.get(BEARER_HEADER_NAME) ??
|
|
265
325
|
req.cookies.get(BEARER_COOKIE_NAME)?.value;
|
|
266
|
-
if (!bearer
|
|
326
|
+
if (!bearer) {
|
|
267
327
|
return tokenExpiredResponse();
|
|
268
328
|
}
|
|
269
|
-
|
|
329
|
+
const result = await verifyBearer(bearer);
|
|
330
|
+
if (!result.ok) {
|
|
331
|
+
return result.reason === 'rejected'
|
|
332
|
+
? tokenExpiredResponse()
|
|
333
|
+
: authUnavailableResponse();
|
|
334
|
+
}
|
|
335
|
+
return getBearerAssembly(bearer, result.identity);
|
|
270
336
|
}
|
|
271
337
|
// Legacy (exchange disabled): authenticate from the session-token header,
|
|
272
338
|
// sent as X-API-Key. Missing token ⇒ 401 marker.
|
package/dist/auth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
|
-
import { getAssembly } from './assembly.js';
|
|
3
|
-
import { BEARER_COOKIE_NAME, BEARER_HEADER_NAME,
|
|
2
|
+
import { authUnavailableResponse, getAssembly } from './assembly.js';
|
|
3
|
+
import { BEARER_COOKIE_NAME, BEARER_HEADER_NAME, isSessionExchangeEnabled, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
|
|
4
|
+
import { verifyBearer } from './verify-bearer.js';
|
|
4
5
|
// Detect an expired/undecryptable-token error from the SDK by shape rather than
|
|
5
6
|
// by importing a concrete class — the foundation builds against whatever SDK
|
|
6
7
|
// version is published, which may not export the error type.
|
|
@@ -14,26 +15,35 @@ function tokenExpiredResponse() {
|
|
|
14
15
|
return NextResponse.json({ error: 'Unauthorized', code: 'token_expired' }, { status: 401, headers: { [TOKEN_EXPIRED_HEADER]: '1' } });
|
|
15
16
|
}
|
|
16
17
|
// Resolves the current viewer's identity for a route handler.
|
|
17
|
-
// Exchange on: bearer-only —
|
|
18
|
-
// load, else the HttpOnly cookie)
|
|
19
|
-
//
|
|
20
|
-
//
|
|
18
|
+
// Exchange on: bearer-only — verifies the bearer with the platform (proxy-
|
|
19
|
+
// forwarded header on first load, else the HttpOnly cookie) and builds the
|
|
20
|
+
// payload from the verified identity, never from the bearer's claims. A
|
|
21
|
+
// missing/rejected bearer returns a 401 with the x-assembly-token-expired marker
|
|
22
|
+
// so the client re-exchanges; a bearer that could not be verified returns a 503
|
|
23
|
+
// without the marker, so nothing re-exchanges. There is no session-token
|
|
24
|
+
// fallback for a bearer-capable app.
|
|
21
25
|
// Exchange off (legacy): reads and decodes the x-assembly-token header.
|
|
22
26
|
export async function requireAuth(req) {
|
|
23
27
|
if (isSessionExchangeEnabled()) {
|
|
24
28
|
const bearer = req.headers.get(BEARER_HEADER_NAME) ??
|
|
25
29
|
req.cookies.get(BEARER_COOKIE_NAME)?.value;
|
|
26
|
-
|
|
27
|
-
if (!session) {
|
|
30
|
+
if (!bearer) {
|
|
28
31
|
return tokenExpiredResponse();
|
|
29
32
|
}
|
|
33
|
+
const result = await verifyBearer(bearer);
|
|
34
|
+
if (!result.ok) {
|
|
35
|
+
return result.reason === 'rejected'
|
|
36
|
+
? tokenExpiredResponse()
|
|
37
|
+
: authUnavailableResponse();
|
|
38
|
+
}
|
|
39
|
+
const { identity } = result;
|
|
30
40
|
return {
|
|
31
|
-
workspaceId:
|
|
32
|
-
clientId:
|
|
33
|
-
internalUserId:
|
|
34
|
-
companyId:
|
|
35
|
-
notificationId:
|
|
36
|
-
instanceId:
|
|
41
|
+
workspaceId: identity.workspaceId,
|
|
42
|
+
clientId: identity.clientId,
|
|
43
|
+
internalUserId: identity.internalUserId,
|
|
44
|
+
companyId: identity.companyId,
|
|
45
|
+
notificationId: identity.notificationId,
|
|
46
|
+
instanceId: identity.instanceId,
|
|
37
47
|
};
|
|
38
48
|
}
|
|
39
49
|
const token = req.headers.get('x-assembly-token');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
type BatchDb = {
|
|
2
|
+
batch(queries: never): Promise<unknown>;
|
|
3
|
+
};
|
|
4
|
+
export type ReplaceChildRowsResult = {
|
|
5
|
+
ok: true;
|
|
6
|
+
ids: string[];
|
|
7
|
+
} | {
|
|
8
|
+
ok: false;
|
|
9
|
+
message: string;
|
|
10
|
+
};
|
|
11
|
+
type ReplaceChildRowsSpec<TRow extends {
|
|
12
|
+
id?: string;
|
|
13
|
+
}, TDelete extends PromiseLike<unknown>, TUpsert extends PromiseLike<unknown>> = {
|
|
14
|
+
rows: readonly TRow[];
|
|
15
|
+
removeAll: () => TDelete;
|
|
16
|
+
removeMissing: (keepIds: readonly string[]) => TDelete;
|
|
17
|
+
upsert: (row: TRow & {
|
|
18
|
+
id: string;
|
|
19
|
+
}) => TUpsert;
|
|
20
|
+
readIds: () => Promise<ReadonlyArray<{
|
|
21
|
+
id: string;
|
|
22
|
+
}>>;
|
|
23
|
+
};
|
|
24
|
+
export declare function replaceChildRows<TRow extends {
|
|
25
|
+
id?: string;
|
|
26
|
+
}, TDelete extends PromiseLike<unknown>, TUpsert extends PromiseLike<unknown>>(db: BatchDb, spec: ReplaceChildRowsSpec<TRow, TDelete, TUpsert>): Promise<ReplaceChildRowsResult>;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Replacing a parent's child rows is where generated apps drop data. The failure
|
|
2
|
+
// that shows up once a parent already has children: insert the new rows, then
|
|
3
|
+
// delete every id that was not in a keep-list built only from rows that already
|
|
4
|
+
// had ids. The insert's id was never on that list, so the same batch deletes it.
|
|
5
|
+
//
|
|
6
|
+
// The neon-http batch method is generic over a one-or-more tuple. Putting that
|
|
7
|
+
// tuple on this parameter makes a real `db` fail to assign, which pushes the
|
|
8
|
+
// cast out to every call site. A function that accepts a specific tuple is
|
|
9
|
+
// assignable to one that accepts `never`, so the cast stays here.
|
|
10
|
+
const childRowsMismatchMessage = 'Save did not keep every row';
|
|
11
|
+
function rowId(row) {
|
|
12
|
+
if (typeof row.id === 'string' && row.id !== '') {
|
|
13
|
+
return row;
|
|
14
|
+
}
|
|
15
|
+
return { ...row, id: crypto.randomUUID() };
|
|
16
|
+
}
|
|
17
|
+
function sameIds(expected, actual) {
|
|
18
|
+
const want = new Set(expected);
|
|
19
|
+
const got = new Set(actual);
|
|
20
|
+
if (want.size !== got.size)
|
|
21
|
+
return false;
|
|
22
|
+
for (const id of want) {
|
|
23
|
+
if (!got.has(id))
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
async function runStatements(db, statements) {
|
|
29
|
+
const [first, ...rest] = statements;
|
|
30
|
+
// One statement is the empty-payload delete. neon-http batch wants two or more.
|
|
31
|
+
if (rest.length === 0) {
|
|
32
|
+
await first;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const batch = db.batch;
|
|
36
|
+
await batch([first, ...rest]);
|
|
37
|
+
}
|
|
38
|
+
export async function replaceChildRows(db, spec) {
|
|
39
|
+
// Ids exist before the delete is built, so a row added in this save is on the keep-list.
|
|
40
|
+
const written = spec.rows.map(rowId);
|
|
41
|
+
const statements = written.length === 0
|
|
42
|
+
? [spec.removeAll()]
|
|
43
|
+
: [spec.removeMissing(written.map((row) => row.id)), ...written.map((row) => spec.upsert(row))];
|
|
44
|
+
await runStatements(db, statements);
|
|
45
|
+
const found = await spec.readIds();
|
|
46
|
+
const ids = written.map((row) => row.id);
|
|
47
|
+
if (!sameIds(ids, found.map((row) => row.id))) {
|
|
48
|
+
return { ok: false, message: childRowsMismatchMessage };
|
|
49
|
+
}
|
|
50
|
+
return { ok: true, ids };
|
|
51
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from './assembly.js';
|
|
|
3
3
|
export * from './auth.js';
|
|
4
4
|
export * from './client-access.js';
|
|
5
5
|
export * from './mint-bearer.js';
|
|
6
|
+
export * from './verify-bearer.js';
|
|
6
7
|
export * from './nango.js';
|
|
7
8
|
export * from './airtable.js';
|
|
8
9
|
export * from './gmail.js';
|
|
@@ -10,6 +11,7 @@ export * from './app-storage.js';
|
|
|
10
11
|
export * from './labels.js';
|
|
11
12
|
export * from './notifications.js';
|
|
12
13
|
export * from './cron.js';
|
|
14
|
+
export * from './replace-rows.js';
|
|
13
15
|
export * from './api-errors.js';
|
|
14
16
|
export * from './params.js';
|
|
15
17
|
export * from './client.js';
|
package/dist/server.js
CHANGED
|
@@ -7,6 +7,7 @@ export * from './assembly.js';
|
|
|
7
7
|
export * from './auth.js';
|
|
8
8
|
export * from './client-access.js';
|
|
9
9
|
export * from './mint-bearer.js';
|
|
10
|
+
export * from './verify-bearer.js';
|
|
10
11
|
export * from './nango.js';
|
|
11
12
|
export * from './airtable.js';
|
|
12
13
|
export * from './gmail.js';
|
|
@@ -14,6 +15,7 @@ export * from './app-storage.js';
|
|
|
14
15
|
export * from './labels.js';
|
|
15
16
|
export * from './notifications.js';
|
|
16
17
|
export * from './cron.js';
|
|
18
|
+
export * from './replace-rows.js';
|
|
17
19
|
// Returns NextResponse, so route handlers only — but nothing here reads a
|
|
18
20
|
// secret, which is why a client file may still `import type` from them.
|
|
19
21
|
export * from './api-errors.js';
|
package/dist/session-exchange.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
// Stateless bearer helpers for the token-exchange flow (AI-260).
|
|
2
2
|
// The proxy exchanges the session token once and sets the returned bearer as an
|
|
3
3
|
// HttpOnly cookie. That bearer is a signed JWT carrying the caller's identity as
|
|
4
|
-
// claims
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// claims. decodeBearer reads those claims without any network call, so it serves
|
|
5
|
+
// as a cheap precheck (shape, expiry, workspace claim); identity itself comes
|
|
6
|
+
// from verifyBearer, which asks the platform. Each runtime instance verifies a
|
|
7
|
+
// given bearer once per cache window, because on Vercel the middleware and the
|
|
8
|
+
// route handlers run in separate instances that never share memory.
|
|
8
9
|
//
|
|
9
|
-
// decodeBearer
|
|
10
|
-
// re-validates the token's signature and scope on every real API call, which is
|
|
11
|
-
// the security boundary; these decoded claims are advisory to the app (SDK init,
|
|
12
|
-
// early authz rejects) and never the sole gate.
|
|
10
|
+
// decodeBearer is a no-network shape and expiry precheck.
|
|
13
11
|
export const BEARER_COOKIE_NAME = 'x-assembly-bearer';
|
|
14
12
|
export const BEARER_TTL_MS = 60 * 60 * 1_000; // 60 minutes, matches bearer TTL
|
|
15
13
|
// The only statuses that prove the session token ITSELF is spent or invalid:
|
|
@@ -176,11 +174,10 @@ function decodeBase64UrlJson(segment) {
|
|
|
176
174
|
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
|
177
175
|
return JSON.parse(new TextDecoder().decode(bytes));
|
|
178
176
|
}
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
// then return 401 / re-exchange.
|
|
177
|
+
// Reads the bearer's claims locally (shape and expiry precheck only). Identity
|
|
178
|
+
// for authorization comes from verifyBearer. Returns null when the token is
|
|
179
|
+
// malformed, expired, or carries no workspace — callers then return 401 /
|
|
180
|
+
// re-exchange.
|
|
184
181
|
export function decodeBearer(bearer) {
|
|
185
182
|
const parts = bearer.split('.');
|
|
186
183
|
if (parts.length !== 3)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface VerifiedIdentity {
|
|
2
|
+
workspaceId: string;
|
|
3
|
+
clientId?: string;
|
|
4
|
+
companyId?: string;
|
|
5
|
+
internalUserId?: string;
|
|
6
|
+
notificationId?: string;
|
|
7
|
+
instanceId?: string;
|
|
8
|
+
sessionId?: string;
|
|
9
|
+
callerAppId?: string;
|
|
10
|
+
installerUserId?: string;
|
|
11
|
+
tokenKind?: string;
|
|
12
|
+
/** Epoch SECONDS at which the verified bearer expires. */
|
|
13
|
+
expiresAt: number;
|
|
14
|
+
}
|
|
15
|
+
export type VerifyResult = {
|
|
16
|
+
ok: true;
|
|
17
|
+
identity: VerifiedIdentity;
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
reason: 'rejected' | 'unavailable';
|
|
21
|
+
};
|
|
22
|
+
export declare const VERIFY_PATH = "/v1/session/verify";
|
|
23
|
+
export declare const VIEWER_BEARER_HEADER = "X-Assembly-Viewer-Bearer";
|
|
24
|
+
export declare const VERIFY_OUTCOME_EVENT = "assembly.verify_outcome";
|
|
25
|
+
/**
|
|
26
|
+
* Resolves a viewer bearer to the identity the platform vouches for. Identity
|
|
27
|
+
* must come from here, never from the bearer's decoded claims.
|
|
28
|
+
*/
|
|
29
|
+
export declare function verifyBearer(bearer: string): Promise<VerifyResult>;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { OpenAPI } from '@assembly-js/node-sdk';
|
|
2
|
+
import { TtlCache } from './resource-resolution.js';
|
|
3
|
+
import { decodeBearer } from './session-exchange.js';
|
|
4
|
+
export const VERIFY_PATH = '/v1/session/verify';
|
|
5
|
+
export const VIEWER_BEARER_HEADER = 'X-Assembly-Viewer-Bearer';
|
|
6
|
+
export const VERIFY_OUTCOME_EVENT = 'assembly.verify_outcome';
|
|
7
|
+
// A verify sits on every authenticated request's critical path, so it gets far
|
|
8
|
+
// less rope than the exchange.
|
|
9
|
+
const VERIFY_TIMEOUT_MS = 2_000;
|
|
10
|
+
// Bounds how long a verdict can outlive a revocation on this instance.
|
|
11
|
+
const POSITIVE_TTL_MS = 60_000;
|
|
12
|
+
// Short enough that a bearer rejected during a transient state recovers quickly,
|
|
13
|
+
// long enough to absorb a burst of requests carrying the same bad bearer.
|
|
14
|
+
const REJECTED_TTL_MS = 10_000;
|
|
15
|
+
const STALE_GRACE_MS = 5 * 60_000;
|
|
16
|
+
// Separate bounds so rejected entries can never evict positive ones.
|
|
17
|
+
const MAX_POSITIVE_ENTRIES = 1_000;
|
|
18
|
+
const MAX_REJECTED_ENTRIES = 1_000;
|
|
19
|
+
// The only 401 codes that prove the bearer itself is refused; any other 401
|
|
20
|
+
// (a gateway or authorizer response) says nothing about it.
|
|
21
|
+
const REJECTION_CODES = new Set(['invalid_token', 'token_expired']);
|
|
22
|
+
// One cache per runtime instance: instances never share memory, so each
|
|
23
|
+
// verifies a given bearer once per TTL.
|
|
24
|
+
const positiveCache = new TtlCache(MAX_POSITIVE_ENTRIES);
|
|
25
|
+
const rejectedCache = new TtlCache(MAX_REJECTED_ENTRIES);
|
|
26
|
+
// The last verdict the platform gave, kept for a short grace period (never past
|
|
27
|
+
// the bearer's own expiry). Only consulted when the platform cannot be reached,
|
|
28
|
+
// so a blip does not take down viewers it had just vouched for; an explicit
|
|
29
|
+
// rejection evicts it. Bounded tightly because during an outage a revocation
|
|
30
|
+
// cannot be seen.
|
|
31
|
+
const lastGoodCache = new TtlCache(MAX_POSITIVE_ENTRIES);
|
|
32
|
+
const inFlight = new Map();
|
|
33
|
+
// Raw bearers are never held in memory beyond the call; the digest is the key.
|
|
34
|
+
async function hashBearer(bearer) {
|
|
35
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(bearer));
|
|
36
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
37
|
+
}
|
|
38
|
+
function optionalString(value) {
|
|
39
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
40
|
+
}
|
|
41
|
+
function parseIdentity(body) {
|
|
42
|
+
if (!body || typeof body !== 'object')
|
|
43
|
+
return null;
|
|
44
|
+
const b = body;
|
|
45
|
+
const workspaceId = optionalString(b.workspaceId);
|
|
46
|
+
if (!workspaceId)
|
|
47
|
+
return null;
|
|
48
|
+
if (typeof b.expiresAt !== 'number' || !Number.isFinite(b.expiresAt)) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
workspaceId,
|
|
53
|
+
clientId: optionalString(b.clientId),
|
|
54
|
+
companyId: optionalString(b.companyId),
|
|
55
|
+
internalUserId: optionalString(b.internalUserId),
|
|
56
|
+
notificationId: optionalString(b.notificationId),
|
|
57
|
+
instanceId: optionalString(b.instanceId),
|
|
58
|
+
sessionId: optionalString(b.sessionId),
|
|
59
|
+
callerAppId: optionalString(b.callerAppId),
|
|
60
|
+
installerUserId: optionalString(b.installerUserId),
|
|
61
|
+
tokenKind: optionalString(b.tokenKind),
|
|
62
|
+
expiresAt: b.expiresAt,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function logOutcome(outcome, startedAt) {
|
|
66
|
+
console.log(JSON.stringify({
|
|
67
|
+
event_type: VERIFY_OUTCOME_EVENT,
|
|
68
|
+
outcome,
|
|
69
|
+
duration_ms: Date.now() - startedAt,
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
// Callers get a copy so a mutation can never reach the cached identity.
|
|
73
|
+
function frozenOk(identity) {
|
|
74
|
+
return { ok: true, identity: Object.freeze({ ...identity }) };
|
|
75
|
+
}
|
|
76
|
+
async function fetchVerdict(bearer) {
|
|
77
|
+
const apiKey = process.env.ASSEMBLY_API_KEY;
|
|
78
|
+
if (!apiKey)
|
|
79
|
+
return { ok: false, reason: 'unavailable' };
|
|
80
|
+
const base = (process.env.ASSEMBLY_API_BASE_URL || OpenAPI.BASE).replace(/\/$/, '');
|
|
81
|
+
const headers = {
|
|
82
|
+
'X-API-Key': apiKey,
|
|
83
|
+
[VIEWER_BEARER_HEADER]: bearer,
|
|
84
|
+
};
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
const timeoutId = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
|
|
87
|
+
try {
|
|
88
|
+
const resp = await fetch(`${base}${VERIFY_PATH}`, {
|
|
89
|
+
headers,
|
|
90
|
+
signal: controller.signal,
|
|
91
|
+
});
|
|
92
|
+
if (resp.status === 200) {
|
|
93
|
+
const identity = parseIdentity(await resp.json());
|
|
94
|
+
return identity
|
|
95
|
+
? { ok: true, identity }
|
|
96
|
+
: { ok: false, reason: 'unavailable' };
|
|
97
|
+
}
|
|
98
|
+
if (resp.status === 401) {
|
|
99
|
+
const body = (await resp.json());
|
|
100
|
+
if (typeof body?.code === 'string' && REJECTION_CODES.has(body.code)) {
|
|
101
|
+
return { ok: false, reason: 'rejected' };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { ok: false, reason: 'unavailable' };
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Network failure, timeout, or a non-JSON body (gateway HTML).
|
|
108
|
+
return { ok: false, reason: 'unavailable' };
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
clearTimeout(timeoutId);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function verifyUncached(bearer, key, startedAt) {
|
|
115
|
+
const result = await fetchVerdict(bearer);
|
|
116
|
+
if (result.ok) {
|
|
117
|
+
// Never outlive the bearer itself.
|
|
118
|
+
const ttl = Math.min(POSITIVE_TTL_MS, result.identity.expiresAt * 1_000 - Date.now());
|
|
119
|
+
positiveCache.set(key, result.identity, ttl);
|
|
120
|
+
lastGoodCache.set(key, result.identity, Math.min(STALE_GRACE_MS, result.identity.expiresAt * 1_000 - Date.now()));
|
|
121
|
+
logOutcome('miss', startedAt);
|
|
122
|
+
return frozenOk(result.identity);
|
|
123
|
+
}
|
|
124
|
+
if (result.reason === 'rejected') {
|
|
125
|
+
rejectedCache.set(key, true, REJECTED_TTL_MS);
|
|
126
|
+
lastGoodCache.delete(key);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
const lastGood = lastGoodCache.get(key);
|
|
130
|
+
if (lastGood) {
|
|
131
|
+
logOutcome('stale', startedAt);
|
|
132
|
+
return frozenOk(lastGood);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
logOutcome(result.reason, startedAt);
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Resolves a viewer bearer to the identity the platform vouches for. Identity
|
|
140
|
+
* must come from here, never from the bearer's decoded claims.
|
|
141
|
+
*/
|
|
142
|
+
export async function verifyBearer(bearer) {
|
|
143
|
+
const startedAt = Date.now();
|
|
144
|
+
// The decode is a no-network precheck: shape, expiry and workspace claim.
|
|
145
|
+
if (!decodeBearer(bearer)) {
|
|
146
|
+
logOutcome('rejected', startedAt);
|
|
147
|
+
return { ok: false, reason: 'rejected' };
|
|
148
|
+
}
|
|
149
|
+
const key = await hashBearer(bearer);
|
|
150
|
+
const cached = positiveCache.get(key);
|
|
151
|
+
if (cached && cached.expiresAt * 1_000 > Date.now()) {
|
|
152
|
+
logOutcome('hit', startedAt);
|
|
153
|
+
return frozenOk(cached);
|
|
154
|
+
}
|
|
155
|
+
if (rejectedCache.get(key)) {
|
|
156
|
+
logOutcome('rejected', startedAt);
|
|
157
|
+
return { ok: false, reason: 'rejected' };
|
|
158
|
+
}
|
|
159
|
+
// Concurrent requests carrying one bearer share a single platform call.
|
|
160
|
+
const pending = inFlight.get(key);
|
|
161
|
+
if (pending) {
|
|
162
|
+
const shared = await pending;
|
|
163
|
+
return shared.ok ? frozenOk(shared.identity) : shared;
|
|
164
|
+
}
|
|
165
|
+
const request = verifyUncached(bearer, key, startedAt).finally(() => {
|
|
166
|
+
inFlight.delete(key);
|
|
167
|
+
});
|
|
168
|
+
inFlight.set(key, request);
|
|
169
|
+
return request;
|
|
170
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assembly-js/app-foundation",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.37-rc.0",
|
|
4
4
|
"description": "Versioned foundation helpers and build context for Assembly generated apps",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
32
|
"build": "tsc && tsc-alias",
|
|
33
|
-
"test": "bun test",
|
|
33
|
+
"test": "bun test && tsc -p tsconfig.replace-rows.json",
|
|
34
34
|
"prepublishOnly": "npm run build && npm run verify:dist && npm run verify:bin",
|
|
35
35
|
"verify:dist": "node scripts/verify-dist-resolves.mjs",
|
|
36
36
|
"verify:bin": "node scripts/verify-bin.mjs"
|
|
@@ -44,8 +44,10 @@
|
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@assembly-js/app-bridge": "1.3.0",
|
|
46
46
|
"@assembly-js/node-sdk": "4.2.4",
|
|
47
|
+
"@neondatabase/serverless": "^1.1.0",
|
|
47
48
|
"@types/bun": "^1.1.0",
|
|
48
49
|
"@types/node": "^22",
|
|
50
|
+
"drizzle-orm": "^0.45.2",
|
|
49
51
|
"next": "^15.5.0",
|
|
50
52
|
"server-only": "^0.0.1",
|
|
51
53
|
"tsc-alias": "^1.9.1",
|
|
@@ -256,7 +256,7 @@ Every event also lands in the workspace's audit log, where a workspace admin rea
|
|
|
256
256
|
|
|
257
257
|
| Header | Value |
|
|
258
258
|
|---|---|
|
|
259
|
-
| `X-Assembly-Actor-Id` | the viewer's id from `requireAuth` — `internalUserId`, else `clientId` |
|
|
259
|
+
| `X-Assembly-Actor-Id` | the viewer's id from `requireAuth` (the platform-verified identity) — `internalUserId`, else `clientId` |
|
|
260
260
|
| `X-Assembly-Source` | `web` when a person did this in the app's UI |
|
|
261
261
|
| `X-Assembly-Client-IP` | the request's `x-forwarded-for` |
|
|
262
262
|
| `X-Assembly-User-Agent` | the request's `user-agent` |
|
|
@@ -14,7 +14,7 @@ const identity = await getSessionIdentity();
|
|
|
14
14
|
// → { workspaceId, clientId?, companyId?, internalUserId? } | null
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
Read identity with `getSessionIdentity()` (server) or `useAssembly()` (client) — see `assembly-session-token`. Never call `assembly.getTokenPayload()` in app code.
|
|
17
|
+
Read identity with `getSessionIdentity()` (server) or `useAssembly()` (client) — see `assembly-session-token`. Never call `assembly.getTokenPayload()` in app code. The identity is the one the platform verified for the bearer, so `clientId` and `companyId` can be trusted for scoping; if verification is unavailable, `getSessionIdentity()` throws `AuthUnavailableError` — render a retry state rather than treating the viewer as signed out.
|
|
18
18
|
|
|
19
19
|
**A client viewer always carries both ids.** `identity.clientId` is who they are; `identity.companyId` is the company they currently have active in the portal switcher. The platform guarantees the active company is populated for a client session, so **treat a client viewer with no `companyId` as an error, not as a case to fall back on** — do not widen the query to the client's other companies, and do not substitute `companyIds[0]`.
|
|
20
20
|
|
|
@@ -334,6 +334,24 @@ await markSubmissionNotified(submission.id);
|
|
|
334
334
|
|
|
335
335
|
Option 2 is cheaper (no list call) and more reliable. Pick option 1 when you don't control the trigger schema.
|
|
336
336
|
|
|
337
|
+
### Recurring conditions
|
|
338
|
+
|
|
339
|
+
A cron that scans for a condition still being true — unassigned for a day, overdue, no reply — selects the same rows on every tick. `createNotification` does not remember a previous send, so each tick creates another copy for every recipient. `sendThrottled` only spaces that flood out; it does not stop it.
|
|
340
|
+
|
|
341
|
+
Record the alert on the subject, in the app's own table, before sending. Key it by the subject and the condition (one thread, one kind), not by the recipient. Skip the send when that marker is set. Clear the marker when the condition ends (the thread is assigned, a reply arrives) so a later recurrence can alert again.
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
// Unique on (threadId, kind). Write the marker first so a tick that dies
|
|
345
|
+
// mid-send skips the rest next time instead of notifying everyone again.
|
|
346
|
+
if (await alreadyAlerted(thread.id, 'stale-unassigned')) continue;
|
|
347
|
+
await markAlerted(thread.id, 'stale-unassigned');
|
|
348
|
+
await sendThrottled(recipients, (u) =>
|
|
349
|
+
assembly.createNotification({ requestBody: { recipientInternalUserId: u.id, /* ... */ } }),
|
|
350
|
+
);
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Do not use `listNotifications` to decide. A sweep will not page the whole center, and a notification the recipient already read still has to count.
|
|
354
|
+
|
|
337
355
|
## Clearing the client portal badge
|
|
338
356
|
|
|
339
357
|
Because clients see a badge counter rather than a Notification Center, every `createNotification({ recipientClientId })` is only half the job — the app must `markNotificationRead` at the right moment or the badge sticks forever. Leaving it unhandled is the most common bug in client-facing apps. There are two patterns:
|
|
@@ -443,6 +461,7 @@ Each client gets their own badge and clears it independently. `recipientCompanyI
|
|
|
443
461
|
## What NOT to do
|
|
444
462
|
|
|
445
463
|
- Do not `Promise.all` a recipient-list fan-out. Writes cap at 5 concurrent; firing a whole list at once 429s and drops notifications. Use `sendThrottled` (see "Throttling fan-outs"). A fixed handful of calls is the only exception.
|
|
464
|
+
- Do not re-send a cron alert because the same condition is still true. Record it on the subject before the send and clear that record when the condition ends (see "Recurring conditions").
|
|
446
465
|
- Do not use `recipientId` — deprecated. Use `recipientClientId` or `recipientInternalUserId`.
|
|
447
466
|
- Do not pass `recipientCompanyId` alone as a recipient. The API rejects it. To notify a company, fan out across its clients (see "Notifying a company").
|
|
448
467
|
- Do not omit the company id when a multi-company client is involved. Notifying a multi-company client needs `recipientCompanyId` (`400 "company ID is required when notifying a client"`); a multi-company client *sending* to an internal user needs `senderCompanyId` (`400 "sender company ID is invalid based on sender"`). Single-company members are auto-inferred. See "Company scoping, by recipient type."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: assembly-record-writes
|
|
3
|
-
description: REQUIRED when a form creates a persisted record that a later save or update also writes,
|
|
3
|
+
description: REQUIRED when a form creates a persisted record that a later save or update also writes, or when a save replaces nested rows (packages, add-ons, line items, blocks, children). Create and save must call one writer so create cannot drop fields the form submitted. Replacing those rows deletes missing ids before the upsert and reads them back before success. A success toast does not mean the rows were stored.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
A create form often submits the same payload a later edit form saves: parent columns plus nested collections. If create inserts only the parent (and maybe one child table and an audit event) while save writes the rest, the first submit looks successful and the editor opens empty. The redirect after create does not call save.
|
|
@@ -9,31 +9,66 @@ A create form often submits the same payload a later edit form saves: parent col
|
|
|
9
9
|
|
|
10
10
|
One function writes the submitted payload. Create calls it for a new id; save calls it for an existing id.
|
|
11
11
|
|
|
12
|
+
Leave these off the shared payload. They belong to the path that owns them:
|
|
13
|
+
|
|
14
|
+
- ids, timestamps, a draft status, an audit-event row
|
|
15
|
+
- a control flag that chooses an action (for example "send immediately") and is not itself stored, unless save stores that same flag
|
|
16
|
+
- fields that appear only on a later edit form
|
|
17
|
+
|
|
18
|
+
If the form submits it, the create call chain writes it, including by calling the shared writer.
|
|
19
|
+
|
|
20
|
+
## Replacing child rows
|
|
21
|
+
|
|
22
|
+
Import `replaceChildRows` from `@assembly-js/app-foundation/server`. Do not reimplement it. Inserting new rows and then deleting every id missing from a keep-list of only the rows that already had ids deletes the rows just inserted. That only shows up once the parent already has children, so the first save looks fine.
|
|
23
|
+
|
|
24
|
+
When the user adds a row, assign `crypto.randomUUID()` on the client before submit. The helper also assigns any id that arrives empty, before it builds the delete, so a missing id cannot fall off the keep-list.
|
|
25
|
+
|
|
26
|
+
Call the helper only for a collection the form submitted (`input.packages !== undefined`). An omitted collection stays as it is. An empty array means the user cleared that collection: every child of that parent is deleted. The helper uses a delete-all for that case and does not emit `id NOT IN ()`.
|
|
27
|
+
|
|
28
|
+
Upsert the parent by id first. A save does not delete and recreate the parent. Then one `replaceChildRows` per submitted collection. The helper deletes missing ids before the upserts, in one `db.batch` when there is more than one statement. Do not use `db.transaction` (see `assembly-db-transactions`). On conflict, update a child only when it already belongs to this parent, so a submitted id from another record cannot change that record.
|
|
29
|
+
|
|
30
|
+
If the result is `{ ok: false, message }`, return that from the server action. The message is `Save did not keep every row`. Do not toast success. The check compares ids only.
|
|
31
|
+
|
|
12
32
|
```ts
|
|
33
|
+
import { replaceChildRows } from '@assembly-js/app-foundation/server';
|
|
34
|
+
import { and, eq, notInArray } from 'drizzle-orm';
|
|
35
|
+
import { db } from '@/lib/db';
|
|
36
|
+
import { packages, proposals } from '@/lib/schema';
|
|
37
|
+
|
|
13
38
|
async function writeProposal(id: string, input: ProposalInput) {
|
|
14
39
|
await db.insert(proposals).values({ id, title: input.title, intro: input.intro })
|
|
15
40
|
.onConflictDoUpdate({ target: proposals.id, set: { title: input.title, intro: input.intro } });
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
41
|
+
|
|
42
|
+
if (input.packages !== undefined) {
|
|
43
|
+
const replaced = await replaceChildRows(db, {
|
|
44
|
+
rows: input.packages,
|
|
45
|
+
removeAll: () => db.delete(packages).where(eq(packages.proposalId, id)),
|
|
46
|
+
removeMissing: (keepIds) =>
|
|
47
|
+
db.delete(packages).where(and(eq(packages.proposalId, id), notInArray(packages.id, [...keepIds]))),
|
|
48
|
+
upsert: (row) =>
|
|
49
|
+
db.insert(packages).values({ id: row.id, proposalId: id, name: row.name })
|
|
50
|
+
.onConflictDoUpdate({
|
|
51
|
+
target: packages.id,
|
|
52
|
+
set: { name: row.name },
|
|
53
|
+
where: eq(packages.proposalId, id),
|
|
54
|
+
}),
|
|
55
|
+
readIds: () => db.select({ id: packages.id }).from(packages).where(eq(packages.proposalId, id)),
|
|
56
|
+
});
|
|
57
|
+
if (!replaced.ok) return replaced;
|
|
58
|
+
}
|
|
19
59
|
}
|
|
20
60
|
|
|
21
61
|
export async function createProposal(input: ProposalInput) {
|
|
22
62
|
const id = crypto.randomUUID();
|
|
23
|
-
await writeProposal(id, input);
|
|
63
|
+
const written = await writeProposal(id, input);
|
|
64
|
+
if (written) return written;
|
|
24
65
|
await recordEvent(id, 'draft_created');
|
|
25
|
-
return id;
|
|
66
|
+
return { ok: true, id };
|
|
26
67
|
}
|
|
27
68
|
|
|
28
69
|
export async function saveProposal(id: string, input: ProposalInput) {
|
|
29
|
-
await writeProposal(id, input);
|
|
70
|
+
const written = await writeProposal(id, input);
|
|
71
|
+
if (written) return written;
|
|
72
|
+
return { ok: true };
|
|
30
73
|
}
|
|
31
74
|
```
|
|
32
|
-
|
|
33
|
-
Leave these off the shared payload. They belong to the path that owns them:
|
|
34
|
-
|
|
35
|
-
- ids, timestamps, a draft status, an audit-event row
|
|
36
|
-
- a control flag that chooses an action (for example "send immediately") and is not itself stored, unless save stores that same flag
|
|
37
|
-
- fields that appear only on a later edit form
|
|
38
|
-
|
|
39
|
-
If the form submits it, the create call chain writes it, including by calling the shared writer.
|
|
@@ -13,7 +13,7 @@ import { requireAuth, requireCompanyScope, handleRouteError } from '@assembly-js
|
|
|
13
13
|
|
|
14
14
|
## How the helpers work
|
|
15
15
|
|
|
16
|
-
**`requireAuth(req)`**
|
|
16
|
+
**`requireAuth(req)`** verifies the bearer (read from the request cookie — no `x-assembly-token` header) with the platform and returns the identity from the platform's verification response. Returns a 401 `NextResponse` (with a `{ code: 'token_expired' }` body and an `x-assembly-token-expired: 1` header) when the bearer is missing, expired or not accepted — `authFetch` detects this and re-exchanges + retries automatically. When the platform cannot be reached to verify, it returns a 503 with `{ code: 'auth_unavailable' }` and no expiry marker: return it as is. The 503 reaches the app, which should render a retry state or let the user retry; it never triggers a re-exchange.
|
|
17
17
|
|
|
18
18
|
**`requireCompanyScope(identity, resource, assembly?)`** asserts the viewer can access a resource scoped to a company **or to a client**:
|
|
19
19
|
|
|
@@ -144,6 +144,7 @@ Rules that fall out of this:
|
|
|
144
144
|
|
|
145
145
|
- **`cronRoute` is not optional.** The route is a public URL; without the wrapper anyone who guesses it can trigger the app's scheduled work under the app's own credentials. Never hand-roll the header check.
|
|
146
146
|
- **Use `getOfflineAssembly()`** for every platform call — there is no viewer and no cookie. `getSessionIdentity()` is `null` here, so a notification's `senderId` must be chosen explicitly (a system/admin internal user); see `assembly-notifications`.
|
|
147
|
+
- **A scan that notifies must remember what it already sent.** Selecting every row that still matches (unassigned, overdue, unanswered) and calling `createNotification` notifies that same set again on the next tick. Record the alert on the subject before the send and clear it when the condition ends. See `assembly-notifications` ("Recurring conditions").
|
|
147
148
|
- **A missed tick is harmless.** Vercel does not retry, but the row stays due until it is marked, so the next tick picks it up. Never mark a row done before its work succeeded.
|
|
148
149
|
- **A stuck row frees itself.** If Vercel stops the function mid-run, the catch never runs and the row stays `running`; the stale-claim clause lets the next tick reclaim it after 15 minutes. Keep `claimedAt` and that clause — without them one hang parks a row forever.
|
|
149
150
|
- **Never compute the next run by adding minutes to `now`.** Use `nextRunAfter`: it anchors to the scheduled time, skips missed occurrences, and keeps daily cadences on their local wall-clock time across daylight-saving changes.
|
|
@@ -174,4 +175,5 @@ Insert a row with `nextRunAt` in the past, run that, and confirm the side effect
|
|
|
174
175
|
- ❌ Don't skip `cronRoute` or compare the header yourself.
|
|
175
176
|
- ❌ Don't type, invent, or default `CRON_SECRET`. Declare it; the platform mints it.
|
|
176
177
|
- ❌ Don't call `getAssembly()` in the dispatcher. There is no viewer; use `getOfflineAssembly()`.
|
|
178
|
+
- ❌ Don't notify from a sweep without a per-subject marker. The next tick selects the same rows and sends again.
|
|
177
179
|
- ❌ Don't replace `vercel.json`. Add `crons` to it.
|
|
@@ -11,7 +11,7 @@ Your job is to follow three rules.
|
|
|
11
11
|
|
|
12
12
|
Never decode a token yourself and never call `AssemblyBridge.sessionToken.*` — that API is the foundation's, not yours.
|
|
13
13
|
|
|
14
|
-
Server-side, `getSessionIdentity()`
|
|
14
|
+
Server-side, `getSessionIdentity()` returns the viewer the platform verified for the bearer (no token to pass; the check is cached briefly per server instance). It returns `null` when unauthenticated, and throws `AuthUnavailableError` when the platform could not be reached to verify — that is not an expiry, so don't catch it into `null`, don't re-exchange on it, and render a retry state ("Couldn't load, try again" with a retry button) where a server component reads identity:
|
|
15
15
|
|
|
16
16
|
```ts
|
|
17
17
|
import { getSessionIdentity } from '@assembly-js/app-foundation/server';
|