@assembly-js/app-foundation 0.0.37-rc.0 → 0.0.38
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 +24 -8
- package/dist/api-errors.js +0 -5
- package/dist/assembly.d.ts +0 -13
- package/dist/assembly.js +57 -123
- package/dist/auth.js +14 -24
- package/dist/replace-rows.js +2 -1
- package/dist/resource-resolution.d.ts +0 -1
- package/dist/resource-resolution.js +0 -3
- package/dist/server.d.ts +0 -1
- package/dist/server.js +0 -1
- package/dist/session-exchange.js +13 -10
- package/package.json +1 -1
- package/skills/assembly-ai/SKILL.md +8 -7
- package/skills/assembly-automations/SKILL.md +1 -1
- package/skills/assembly-clients-companies/SKILL.md +1 -1
- package/skills/assembly-notifications/SKILL.md +8 -12
- package/skills/assembly-route-auth/SKILL.md +1 -1
- package/skills/assembly-scheduled-work/SKILL.md +1 -1
- package/skills/assembly-session-token/SKILL.md +1 -1
- package/dist/verify-bearer.d.ts +0 -29
- package/dist/verify-bearer.js +0 -170
package/CLAUDE.md
CHANGED
|
@@ -254,7 +254,7 @@ Two things flow into the running app from Assembly:
|
|
|
254
254
|
| | Where | Visibility |
|
|
255
255
|
|---|---|---|
|
|
256
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. |
|
|
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()`
|
|
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()` decodes the viewer. See "How auth works". |
|
|
258
258
|
|
|
259
259
|
---
|
|
260
260
|
|
|
@@ -647,11 +647,11 @@ const assembly = await getAssembly(); // no token — the bearer comes from
|
|
|
647
647
|
const items = await assembly.listItems({});
|
|
648
648
|
```
|
|
649
649
|
|
|
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.
|
|
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.
|
|
651
651
|
|
|
652
652
|
#### Identity — who is the viewer?
|
|
653
653
|
|
|
654
|
-
Server-side, `getSessionIdentity()` returns the viewer
|
|
654
|
+
Server-side, `getSessionIdentity()` returns the viewer decoded from the bearer (no token, no API call):
|
|
655
655
|
|
|
656
656
|
```ts
|
|
657
657
|
import { getSessionIdentity } from '@assembly-js/app-foundation/server';
|
|
@@ -714,9 +714,25 @@ For an imperative one-off (button handler, mutation), use `callAction` from `use
|
|
|
714
714
|
- **Actions take no token.** Never accept, read, or pass a token — `getAssembly()` resolves the bearer itself. Never touch `AssemblyBridge.sessionToken.*` in a component or copy anything into your own `useState`/`useRef`.
|
|
715
715
|
- **Data actions must let `TokenInvalidError` propagate** — never `try/catch` it into `null`/empty. The auto-retry only fires if the action surfaces the expiry. And never hand-roll refresh/retry; `useServerAction`/`callAction` already do it.
|
|
716
716
|
|
|
717
|
+
#### Server action or route handler?
|
|
718
|
+
|
|
719
|
+
A client-to-server call is a server action by default. Its argument is a TypeScript parameter, so a payload that does not match the signature fails `tsc` in your inner loop. A route handler's body is an untyped wire contract — `JSON.stringify` on one side, `request.json()` on the other — and nothing checks that the two agree until a user sends the first request, which you cannot do from the sandbox. A mismatch `tsc` would catch in an action ships silently through a route and 400s for every user.
|
|
720
|
+
|
|
721
|
+
Write a route handler only when the caller needs a raw `Response` — streamed bytes, a file download, a webhook or cron entry point — or when the caller is not this app's own client component. When you do, define the body schema in a shared `lib/` module with no server-only imports, and type the client's payload with `z.input<typeof schema>` so the compiler checks the wire shape:
|
|
722
|
+
|
|
723
|
+
```ts
|
|
724
|
+
// lib/reply-contract.ts — imported by both the route and the component (`bun add zod` if it is not installed yet)
|
|
725
|
+
import { z } from 'zod';
|
|
726
|
+
export const replyBody = z.object({ parentId: z.string().uuid().nullish(), text: z.string().min(1) });
|
|
727
|
+
export type ReplyBody = z.input<typeof replyBody>;
|
|
728
|
+
|
|
729
|
+
// app/api/replies/route.ts: const parsed = replyBody.safeParse(await request.json());
|
|
730
|
+
// component: const body: ReplyBody = { parentId, text }; // tsc rejects what the route would 400
|
|
731
|
+
```
|
|
732
|
+
|
|
717
733
|
#### Client → server via route handlers — `authFetch`
|
|
718
734
|
|
|
719
|
-
For
|
|
735
|
+
For the route-handler calls that remain, use `authFetch` from `app/components/lib-client.ts`. The bearer rides the cookie automatically; `authFetch` adds the required CSRF header and re-exchanges + retries once on a bearer expiry:
|
|
720
736
|
|
|
721
737
|
```tsx
|
|
722
738
|
'use client';
|
|
@@ -800,7 +816,7 @@ Two pre-built helpers guard API routes that operate on company-scoped resources:
|
|
|
800
816
|
import { requireAuth, requireCompanyScope } from '@assembly-js/app-foundation/server';
|
|
801
817
|
```
|
|
802
818
|
|
|
803
|
-
**`requireAuth(req)`**
|
|
819
|
+
**`requireAuth(req)`** decodes the viewer's identity from the bearer and returns it. Returns a 401 `NextResponse` with a `{ code: 'token_expired' }` body **and** `x-assembly-token-expired: 1` header when the bearer is missing/expired — `authFetch` detects this and re-exchanges + retries automatically.
|
|
804
820
|
|
|
805
821
|
**`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`.
|
|
806
822
|
|
|
@@ -870,7 +886,7 @@ So there is nothing to refresh, subscribe to, or thread. Don't touch `AssemblyBr
|
|
|
870
886
|
|
|
871
887
|
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).
|
|
872
888
|
|
|
873
|
-
Server-side (`getSessionIdentity()`
|
|
889
|
+
Server-side (`getSessionIdentity()` decodes the bearer; no API call):
|
|
874
890
|
|
|
875
891
|
```ts
|
|
876
892
|
import { getSessionIdentity } from '@assembly-js/app-foundation/server';
|
|
@@ -1160,7 +1176,7 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
|
|
|
1160
1176
|
- ❌ Don't make Assembly API calls from client components without a server-side proxy. The apiKey can't leave the server.
|
|
1161
1177
|
- ❌ 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.
|
|
1162
1178
|
- ❌ Don't use `assembly.clients.list()` — the SDK is flat. Use `assembly.listClients(...)`.
|
|
1163
|
-
- ❌ Don't cache
|
|
1179
|
+
- ❌ Don't cache decoded payloads for >30s — token rotates every 5 min and the user could be different on the next call (rare, but possible if a session is shared across tabs).
|
|
1164
1180
|
<!-- !feature:app-owned-header -->
|
|
1165
1181
|
- ❌ Don't render your own breadcrumbs / titles in the DOM — use `AssemblyBridge.header.*`.
|
|
1166
1182
|
- ❌ 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.
|
|
@@ -1181,7 +1197,7 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
|
|
|
1181
1197
|
- ❌ Don't hardcode mutation error messages like "Failed to save preference". Read `error` from the response body and surface it to the user.
|
|
1182
1198
|
- ❌ 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`.
|
|
1183
1199
|
- ❌ 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.
|
|
1200
|
+
- ❌ Don't re-notify a still-true condition from a cron. Mark a recipient sent only after `createNotification` succeeds, and clear that record when the condition ends unless the alert is once per billing period; keep those successful outcomes until the period resets, even if usage dips below the threshold. See "Recurring conditions" in `assembly-notifications`.
|
|
1185
1201
|
- ❌ Don't use title case — see "Labels, copy, and dropdowns" above.
|
|
1186
1202
|
- ❌ Don't render raw IDs or pass a raw ID string as `value` on `ComboboxItem` — see "Select and Combobox — base-ui differences" above.
|
|
1187
1203
|
- ❌ 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`.
|
package/dist/api-errors.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
|
-
import { AuthUnavailableError, authUnavailableResponse } from './assembly.js';
|
|
3
2
|
export function badRequest(message) {
|
|
4
3
|
return NextResponse.json({ error: message }, { status: 400 });
|
|
5
4
|
}
|
|
@@ -27,10 +26,6 @@ function isInvalidInputDbError(err) {
|
|
|
27
26
|
// to the UI when handlers do that.
|
|
28
27
|
export function handleRouteError(err, fallback = 'Something went wrong') {
|
|
29
28
|
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
|
-
}
|
|
34
29
|
if (isInvalidInputDbError(err)) {
|
|
35
30
|
return badRequest('Invalid request');
|
|
36
31
|
}
|
package/dist/assembly.d.ts
CHANGED
|
@@ -19,17 +19,6 @@ 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
|
-
}
|
|
33
22
|
export declare function getAssembly(input?: AssemblyInput): Promise<AssemblyAPI>;
|
|
34
23
|
/**
|
|
35
24
|
* Runs `task` after the response is sent (Next's `after()`), with a viewer-
|
|
@@ -59,8 +48,6 @@ export declare function getAssembly(input?: AssemblyInput): Promise<AssemblyAPI>
|
|
|
59
48
|
export declare function afterAssembly(task: (assembly: AssemblyAPI) => Promise<unknown>, options?: {
|
|
60
49
|
token?: string;
|
|
61
50
|
}): void;
|
|
62
|
-
export declare const AUTH_UNAVAILABLE_CODE = "auth_unavailable";
|
|
63
|
-
export declare function authUnavailableResponse(): NextResponse;
|
|
64
51
|
/**
|
|
65
52
|
* OFFLINE / BACKGROUND ONLY — cron jobs, webhook handlers, and any other code
|
|
66
53
|
* path where there is NO user in the loop and thus no session token or bearer
|
package/dist/assembly.js
CHANGED
|
@@ -3,55 +3,33 @@ 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';
|
|
7
6
|
// Trusted API origin for this stack, supplied by the build environment — NOT by
|
|
8
7
|
// a token claim. Empty in prod/shared-staging (the SDK's env default is already
|
|
9
8
|
// correct); set to a per-dev private stack's platform-api URL otherwise. Passed
|
|
10
9
|
// to the SDK as `baseUrl` so tokenless (cookie-only) calls reach the right stack.
|
|
11
10
|
const trustedBaseUrl = process.env.ASSEMBLY_API_BASE_URL || undefined;
|
|
12
|
-
// Reads the viewer bearer
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// prerender) — not an error.
|
|
17
|
-
|
|
11
|
+
// Reads the viewer bearer for the current request, independent of the exchange
|
|
12
|
+
// flag. First the header the proxy forwards on first load (the exchange just
|
|
13
|
+
// minted it; the Set-Cookie isn't yet visible), then the HttpOnly cookie on every
|
|
14
|
+
// later request. Returns undefined outside a request scope (e.g. build-time
|
|
15
|
+
// prerender) — not an error. Used both to resolve identity and, in
|
|
16
|
+
// getOfflineAssembly, to detect that a viewer is present.
|
|
17
|
+
async function readViewerBearer() {
|
|
18
18
|
try {
|
|
19
19
|
const fromProxy = (await headers()).get(BEARER_HEADER_NAME);
|
|
20
|
+
if (fromProxy && decodeBearer(fromProxy))
|
|
21
|
+
return fromProxy;
|
|
20
22
|
const fromCookie = (await cookies()).get(BEARER_COOKIE_NAME)?.value;
|
|
21
|
-
|
|
23
|
+
if (fromCookie && decodeBearer(fromCookie))
|
|
24
|
+
return fromCookie;
|
|
22
25
|
}
|
|
23
26
|
catch {
|
|
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();
|
|
27
|
+
return undefined;
|
|
44
28
|
}
|
|
45
29
|
return undefined;
|
|
46
30
|
}
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
async function resolveRequestViewer() {
|
|
50
|
-
if (!isSessionExchangeEnabled())
|
|
51
|
-
return undefined;
|
|
52
|
-
return readVerifiedViewer();
|
|
53
|
-
}
|
|
54
|
-
// Unverified bearer for deferred work; see readViewerBearer.
|
|
31
|
+
// Viewer bearer for request-scoped auth: only consulted when the exchange flow is
|
|
32
|
+
// on (the legacy path carries identity in proxy headers, not a bearer cookie).
|
|
55
33
|
async function resolveRequestBearer() {
|
|
56
34
|
if (!isSessionExchangeEnabled())
|
|
57
35
|
return undefined;
|
|
@@ -86,22 +64,15 @@ async function hasViewerRequest() {
|
|
|
86
64
|
return false;
|
|
87
65
|
}
|
|
88
66
|
}
|
|
89
|
-
// Server-only: the current user's identity,
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
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.
|
|
67
|
+
// Server-only: the current user's identity, resolved from the bearer (the
|
|
68
|
+
// proxy-forwarded header on first load, else the HttpOnly cookie). This is how
|
|
69
|
+
// the app learns who the viewer is — workspace, client/company, internal user —
|
|
70
|
+
// without ever handling the raw session token. Used to seed the client provider
|
|
71
|
+
// and by server components. Returns null when unauthenticated or when the
|
|
72
|
+
// exchange is disabled (the legacy path carries identity in proxy headers).
|
|
97
73
|
export async function getSessionIdentity() {
|
|
98
|
-
const
|
|
99
|
-
|
|
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 };
|
|
74
|
+
const bearer = await resolveRequestBearer();
|
|
75
|
+
return bearer ? decodeBearer(bearer) : null;
|
|
105
76
|
}
|
|
106
77
|
/**
|
|
107
78
|
* Thrown when a session token is required but missing, or was supplied but
|
|
@@ -120,20 +91,6 @@ export class TokenInvalidError extends Error {
|
|
|
120
91
|
this.name = 'TokenInvalidError';
|
|
121
92
|
}
|
|
122
93
|
}
|
|
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
|
-
}
|
|
137
94
|
export async function getAssembly(input) {
|
|
138
95
|
const token = typeof input === 'string' ? input : input?.token;
|
|
139
96
|
// Exchange enabled ⇒ this is a bearer-capable app, and it authenticates via
|
|
@@ -147,25 +104,31 @@ export async function getAssembly(input) {
|
|
|
147
104
|
// after() and pass it through explicitly as `input.token`; this honors that
|
|
148
105
|
// pre-resolved bearer instead of re-deriving it from request scope. The
|
|
149
106
|
// 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.
|
|
151
107
|
if (isSessionExchangeEnabled()) {
|
|
152
|
-
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
if (result.ok) {
|
|
156
|
-
viewer = { bearer: token, identity: result.identity };
|
|
157
|
-
}
|
|
158
|
-
else if (result.reason === 'unavailable') {
|
|
159
|
-
throw new AuthUnavailableError();
|
|
160
|
-
}
|
|
108
|
+
const bearer = token ?? (await resolveRequestBearer());
|
|
109
|
+
if (!bearer) {
|
|
110
|
+
throw new TokenInvalidError('Assembly bearer is required');
|
|
161
111
|
}
|
|
162
|
-
|
|
163
|
-
|
|
112
|
+
const assembly = await assemblyApi({ bearer, baseUrl: trustedBaseUrl });
|
|
113
|
+
const payload = await assembly.getTokenPayload?.();
|
|
114
|
+
if (!payload?.workspaceId) {
|
|
115
|
+
throw new TokenInvalidError();
|
|
164
116
|
}
|
|
165
|
-
|
|
166
|
-
|
|
117
|
+
// The published SDK's bearer-mode payload maps only the identity claims,
|
|
118
|
+
// so overlay the embed-context claims here to keep getTokenPayload()'s
|
|
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);
|
|
167
130
|
}
|
|
168
|
-
return
|
|
131
|
+
return assembly;
|
|
169
132
|
}
|
|
170
133
|
// Exchange disabled (legacy apps built before this feature, or a rollback):
|
|
171
134
|
// the raw session token is the credential and is sent as X-API-Key, exactly
|
|
@@ -235,35 +198,13 @@ export function afterAssembly(task, options) {
|
|
|
235
198
|
function tokenExpiredResponse() {
|
|
236
199
|
return NextResponse.json({ error: 'Unauthorized', code: 'token_expired' }, { status: 401, headers: { [TOKEN_EXPIRED_HEADER]: '1' } });
|
|
237
200
|
}
|
|
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
|
-
}
|
|
245
201
|
// Builds a bearer-only SDK client. The SDK's native `bearer` option sends
|
|
246
202
|
// Authorization: Bearer on every request — generated service methods and fetch
|
|
247
|
-
// alike — and, with no apiKey supplied, sends NO X-API-Key.
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
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;
|
|
203
|
+
// alike — and, with no apiKey supplied, sends NO X-API-Key. Identity comes from
|
|
204
|
+
// the bearer's own signed claims (getTokenPayload() reads them); the API origin
|
|
205
|
+
// comes from the trusted build-env baseUrl, never a token/bearer claim.
|
|
206
|
+
async function getBearerAssembly(bearer) {
|
|
207
|
+
return assemblyApi({ bearer, baseUrl: trustedBaseUrl });
|
|
267
208
|
}
|
|
268
209
|
/**
|
|
269
210
|
* OFFLINE / BACKGROUND ONLY — cron jobs, webhook handlers, and any other code
|
|
@@ -309,30 +250,23 @@ export async function getOfflineAssembly() {
|
|
|
309
250
|
}
|
|
310
251
|
// Returns a request-scoped SDK client authenticated for the current user.
|
|
311
252
|
//
|
|
312
|
-
// Exchange enabled: bearer-only. A bearer
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
// verified yields a 503 without that marker. There is no session-token fallback.
|
|
253
|
+
// Exchange enabled: bearer-only. A valid bearer (proxy header or cookie) yields
|
|
254
|
+
// a client that carries Authorization: Bearer on every request; a missing or
|
|
255
|
+
// undecodable bearer yields a 401 NextResponse so the client re-exchanges via
|
|
256
|
+
// the x-assembly-token-expired convention. There is no session-token fallback.
|
|
317
257
|
// Exchange disabled: the legacy session-token (X-API-Key) path.
|
|
318
258
|
export async function getAssemblyForRequest(req) {
|
|
319
259
|
if (isSessionExchangeEnabled()) {
|
|
320
260
|
// Bearer-only: the proxy-forwarded header on first load, else the HttpOnly
|
|
321
|
-
// cookie.
|
|
322
|
-
//
|
|
323
|
-
// bearer-capable app.
|
|
261
|
+
// cookie. Its signed claims carry identity for SDK init. No bearer (or an
|
|
262
|
+
// undecodable/expired one) ⇒ 401 marker so the client re-exchanges; there
|
|
263
|
+
// is no X-API-Key / session-token fallback for a bearer-capable app.
|
|
324
264
|
const bearer = req.headers.get(BEARER_HEADER_NAME) ??
|
|
325
265
|
req.cookies.get(BEARER_COOKIE_NAME)?.value;
|
|
326
|
-
if (!bearer) {
|
|
266
|
+
if (!bearer || !decodeBearer(bearer)) {
|
|
327
267
|
return tokenExpiredResponse();
|
|
328
268
|
}
|
|
329
|
-
|
|
330
|
-
if (!result.ok) {
|
|
331
|
-
return result.reason === 'rejected'
|
|
332
|
-
? tokenExpiredResponse()
|
|
333
|
-
: authUnavailableResponse();
|
|
334
|
-
}
|
|
335
|
-
return getBearerAssembly(bearer, result.identity);
|
|
269
|
+
return getBearerAssembly(bearer);
|
|
336
270
|
}
|
|
337
271
|
// Legacy (exchange disabled): authenticate from the session-token header,
|
|
338
272
|
// sent as X-API-Key. Missing token ⇒ 401 marker.
|
package/dist/auth.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
|
-
import {
|
|
3
|
-
import { BEARER_COOKIE_NAME, BEARER_HEADER_NAME, isSessionExchangeEnabled, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
|
|
4
|
-
import { verifyBearer } from './verify-bearer.js';
|
|
2
|
+
import { getAssembly } from './assembly.js';
|
|
3
|
+
import { BEARER_COOKIE_NAME, BEARER_HEADER_NAME, decodeBearer, isSessionExchangeEnabled, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
|
|
5
4
|
// Detect an expired/undecryptable-token error from the SDK by shape rather than
|
|
6
5
|
// by importing a concrete class — the foundation builds against whatever SDK
|
|
7
6
|
// version is published, which may not export the error type.
|
|
@@ -15,35 +14,26 @@ function tokenExpiredResponse() {
|
|
|
15
14
|
return NextResponse.json({ error: 'Unauthorized', code: 'token_expired' }, { status: 401, headers: { [TOKEN_EXPIRED_HEADER]: '1' } });
|
|
16
15
|
}
|
|
17
16
|
// Resolves the current viewer's identity for a route handler.
|
|
18
|
-
// Exchange on: bearer-only —
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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.
|
|
17
|
+
// Exchange on: bearer-only — decodes the bearer (proxy-forwarded header on first
|
|
18
|
+
// load, else the HttpOnly cookie); a missing/undecodable bearer returns a 401
|
|
19
|
+
// with the x-assembly-token-expired marker so the client re-exchanges. There is
|
|
20
|
+
// no session-token fallback for a bearer-capable app.
|
|
25
21
|
// Exchange off (legacy): reads and decodes the x-assembly-token header.
|
|
26
22
|
export async function requireAuth(req) {
|
|
27
23
|
if (isSessionExchangeEnabled()) {
|
|
28
24
|
const bearer = req.headers.get(BEARER_HEADER_NAME) ??
|
|
29
25
|
req.cookies.get(BEARER_COOKIE_NAME)?.value;
|
|
30
|
-
|
|
26
|
+
const session = bearer ? decodeBearer(bearer) : null;
|
|
27
|
+
if (!session) {
|
|
31
28
|
return tokenExpiredResponse();
|
|
32
29
|
}
|
|
33
|
-
const result = await verifyBearer(bearer);
|
|
34
|
-
if (!result.ok) {
|
|
35
|
-
return result.reason === 'rejected'
|
|
36
|
-
? tokenExpiredResponse()
|
|
37
|
-
: authUnavailableResponse();
|
|
38
|
-
}
|
|
39
|
-
const { identity } = result;
|
|
40
30
|
return {
|
|
41
|
-
workspaceId:
|
|
42
|
-
clientId:
|
|
43
|
-
internalUserId:
|
|
44
|
-
companyId:
|
|
45
|
-
notificationId:
|
|
46
|
-
instanceId:
|
|
31
|
+
workspaceId: session.workspaceId,
|
|
32
|
+
clientId: session.clientId,
|
|
33
|
+
internalUserId: session.internalUserId,
|
|
34
|
+
companyId: session.companyId,
|
|
35
|
+
notificationId: session.notificationId,
|
|
36
|
+
instanceId: session.instanceId,
|
|
47
37
|
};
|
|
48
38
|
}
|
|
49
39
|
const token = req.headers.get('x-assembly-token');
|
package/dist/replace-rows.js
CHANGED
|
@@ -33,7 +33,8 @@ async function runStatements(db, statements) {
|
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
35
35
|
const batch = db.batch;
|
|
36
|
-
|
|
36
|
+
// drizzle's batch reads this.session, so it must be invoked on db, never as a bare function.
|
|
37
|
+
await batch.call(db, [first, ...rest]);
|
|
37
38
|
}
|
|
38
39
|
export async function replaceChildRows(db, spec) {
|
|
39
40
|
// Ids exist before the delete is built, so a row added in this save is on the keep-list.
|
package/dist/server.d.ts
CHANGED
package/dist/server.js
CHANGED
|
@@ -7,7 +7,6 @@ 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';
|
|
11
10
|
export * from './nango.js';
|
|
12
11
|
export * from './airtable.js';
|
|
13
12
|
export * from './gmail.js';
|
package/dist/session-exchange.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
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
|
-
//
|
|
8
|
-
// route handlers run in separate instances that never share memory.
|
|
4
|
+
// claims, so requireAuth()/getAssemblyForRequest() resolve identity by DECODING
|
|
5
|
+
// the cookie — no server-side cache. A cache can't work here anyway: on Vercel
|
|
6
|
+
// the middleware that would populate it and the route handlers that read it run
|
|
7
|
+
// in separate runtimes/instances and never share memory.
|
|
9
8
|
//
|
|
10
|
-
// decodeBearer
|
|
9
|
+
// decodeBearer does NOT verify the signature. The platform-api authorizer
|
|
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.
|
|
11
13
|
export const BEARER_COOKIE_NAME = 'x-assembly-bearer';
|
|
12
14
|
export const BEARER_TTL_MS = 60 * 60 * 1_000; // 60 minutes, matches bearer TTL
|
|
13
15
|
// The only statuses that prove the session token ITSELF is spent or invalid:
|
|
@@ -174,10 +176,11 @@ function decodeBase64UrlJson(segment) {
|
|
|
174
176
|
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
|
175
177
|
return JSON.parse(new TextDecoder().decode(bytes));
|
|
176
178
|
}
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
179
|
+
// Decodes the identity claims from a bearer JWT. Signature is NOT verified here
|
|
180
|
+
// (the platform-api authorizer re-validates it on every real call — the true
|
|
181
|
+
// boundary); these claims are advisory for SDK init and early authz. Returns
|
|
182
|
+
// null when the token is malformed, expired, or carries no workspace — callers
|
|
183
|
+
// then return 401 / re-exchange.
|
|
181
184
|
export function decodeBearer(bearer) {
|
|
182
185
|
const parts = bearer.split('.');
|
|
183
186
|
if (parts.length !== 3)
|
package/package.json
CHANGED
|
@@ -37,25 +37,26 @@ export async function chatCompletion(messages: Array<{ role: string; content: st
|
|
|
37
37
|
|
|
38
38
|
## Server-side only
|
|
39
39
|
|
|
40
|
-
ALL AI calls MUST happen server-side (
|
|
40
|
+
ALL AI calls MUST happen server-side (server actions, server components, route handlers). Never call the AI proxy from client components.
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
Expose an AI call to the client as a server action — its arguments are type-checked end to end, so a payload mismatch fails `tsc` instead of the user's first request. Reserve a route handler for the streaming case below, where the client needs the raw `Response`.
|
|
43
43
|
|
|
44
44
|
```typescript
|
|
45
|
-
// app/
|
|
46
|
-
|
|
45
|
+
// app/actions/summarize.ts
|
|
46
|
+
'use server';
|
|
47
47
|
import { chatCompletion } from '@/lib/ai';
|
|
48
48
|
|
|
49
|
-
export async function
|
|
50
|
-
const { text } = await request.json();
|
|
49
|
+
export async function summarize(text: string): Promise<string> {
|
|
51
50
|
const response = await chatCompletion([
|
|
52
51
|
{ role: 'user', content: `Summarize the following:\n\n${text}` },
|
|
53
52
|
]);
|
|
54
53
|
const data = await response.json();
|
|
55
|
-
return
|
|
54
|
+
return data.choices[0].message.content;
|
|
56
55
|
}
|
|
57
56
|
```
|
|
58
57
|
|
|
58
|
+
From a client component: `const summary = await callAction(() => summarize(text), { idempotent: true });` — the call writes nothing, so it is safe to re-run after a bearer renewal.
|
|
59
|
+
|
|
59
60
|
## Vision (image inputs)
|
|
60
61
|
|
|
61
62
|
The proxy supports OpenAI's vision API. Use `gpt-4o` with the multi-part content format to analyze images. The `content` field becomes an array of typed parts instead of a plain string:
|
|
@@ -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`
|
|
259
|
+
| `X-Assembly-Actor-Id` | the viewer's id from `requireAuth` — `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.
|
|
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
|
|
|
@@ -10,7 +10,9 @@ description: Use when the app must notify internal users or clients, or needs pe
|
|
|
10
10
|
|
|
11
11
|
## Mental model
|
|
12
12
|
|
|
13
|
-
Send a notification when something happens that the recipient should know about
|
|
13
|
+
Send a notification when something happens that the recipient should know about — action required (review, approve, respond) or awareness (status changes, completions, new messages). Skip routine reads, navigation, trivial state changes, and routine confirmations of the recipient's own actions.
|
|
14
|
+
|
|
15
|
+
For a required condition alert (a retainer reaching 90%, going over its allowance, or a budget crossing its limit), the audience is everyone specified by the requirement who has access, including the user whose action crossed the threshold. A sole admin logging usage must still receive the staff alert. Choose recipients by access; use the actor as sender without automatically removing them from the audience.
|
|
14
16
|
|
|
15
17
|
Every notification is three decisions: **audience** (internal users and/or clients), **channel** (in-product, email, or both), and **context on arrival** (what the recipient sees and can do). Get those right; the rest is mechanics.
|
|
16
18
|
|
|
@@ -338,17 +340,11 @@ Option 2 is cheaper (no list call) and more reliable. Pick option 1 when you don
|
|
|
338
340
|
|
|
339
341
|
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
342
|
|
|
341
|
-
|
|
343
|
+
Track each condition occurrence in the app's own table, keyed by subject and condition (include the billing period for a recurring allowance). Resolve the eligible audience before claiming the occurrence: an empty audience creates no sent marker.
|
|
342
344
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
```
|
|
345
|
+
A concurrency claim reserves work; it is not proof of delivery. Keep pending claims retryable after a failure or an expired lease, and mark a recipient sent only after their `createNotification` succeeds. On partial failure, retry the failed recipients and skip those already sent. If there is only one recipient, track that outcome on the occurrence itself. Clear the occurrence when the condition ends if recurrence should alert again. For once-per-period threshold alerts, keep successful outcomes through dips below the threshold and use a new occurrence when the period resets.
|
|
346
|
+
|
|
347
|
+
A permanent marker written before fan-out can consume an alert without sending anything. Marking the whole occurrence sent after `sendThrottled` is also insufficient: it returns per-recipient outcomes, so check failures before declaring delivery complete.
|
|
352
348
|
|
|
353
349
|
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
350
|
|
|
@@ -461,7 +457,7 @@ Each client gets their own badge and clears it independently. `recipientCompanyI
|
|
|
461
457
|
## What NOT to do
|
|
462
458
|
|
|
463
459
|
- 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
|
|
460
|
+
- Do not re-send a condition alert to recipients already notified for that occurrence. Keep failed sends retryable and track successful outcomes (see "Recurring conditions").
|
|
465
461
|
- Do not use `recipientId` — deprecated. Use `recipientClientId` or `recipientInternalUserId`.
|
|
466
462
|
- 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").
|
|
467
463
|
- 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."
|
|
@@ -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)`** decodes the viewer's identity from the bearer (read from the request cookie — no `x-assembly-token` header) and returns it. Returns a 401 `NextResponse` (with a `{ code: 'token_expired' }` body and an `x-assembly-token-expired: 1` header) when the bearer is missing or expired — `authFetch` detects this and re-exchanges + retries automatically.
|
|
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,7 +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.
|
|
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. Mark a recipient sent only after `createNotification` succeeds, and clear that record when the condition ends unless the alert is once per billing period; keep those successful outcomes until the period resets, even if usage dips below the threshold. See `assembly-notifications` ("Recurring conditions").
|
|
148
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.
|
|
149
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.
|
|
150
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.
|
|
@@ -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()` decodes the viewer from the bearer (no token, no API call). It returns `null` when unauthenticated:
|
|
15
15
|
|
|
16
16
|
```ts
|
|
17
17
|
import { getSessionIdentity } from '@assembly-js/app-foundation/server';
|
package/dist/verify-bearer.d.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
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>;
|
package/dist/verify-bearer.js
DELETED
|
@@ -1,170 +0,0 @@
|
|
|
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
|
-
}
|