@assembly-js/app-foundation 0.0.4 → 0.0.6
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 +4 -9
- package/dist/assembly.js +2 -2
- package/dist/auth.js +20 -10
- package/dist/lib-client.d.ts +2 -0
- package/dist/lib-client.js +83 -0
- package/dist/mint-bearer.d.ts +12 -2
- package/dist/mint-bearer.js +15 -7
- package/dist/session-exchange.d.ts +4 -0
- package/dist/session-exchange.js +16 -0
- package/package.json +7 -1
- package/skills/assembly-app-surfaces/SKILL.md +2 -2
- package/skills/assembly-automations/SKILL.md +43 -35
- package/skills/assembly-clients-companies/SKILL.md +1 -1
- package/skills/assembly-notifications/SKILL.md +1 -1
- package/skills/assembly-route-auth/SKILL.md +5 -36
package/CLAUDE.md
CHANGED
|
@@ -31,7 +31,7 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
|
|
|
31
31
|
<!-- /feature:notification-settings -->
|
|
32
32
|
- `clearing-client-notifications` — REQUIRED when the app sends client notifications: clear the portal sidebar badge with markNotificationRead (clear-on-view vs clear-on-action)
|
|
33
33
|
<!-- feature:api-resources -->
|
|
34
|
-
- `assembly-automations` — REQUIRED when the request asks for a platform-side effect in reaction to an in-app event (a message, a CRM field, a contract, a form, a file — not the app's own tables), even if the user never says "automation": declare the app's API contract in `.assembly/api-resources.json` (a separate file from `app.json`) — domain events that become automation triggers + workspace webhooks, and staff-invokable operations that become automation actions. Declared events must also be emitted at runtime
|
|
34
|
+
- `assembly-automations` — REQUIRED when the request asks for a platform-side effect in reaction to an in-app event (a message, a CRM field, a contract, a form, a file — not the app's own tables), even if the user never says "automation": declare the app's API contract in `.assembly/api-resources.json` (a separate file from `app.json`) — domain events that become automation triggers + workspace webhooks, and staff-invokable operations that become automation actions. Declared events must also be emitted at runtime — inside `after()`, reporting the acting user on the documented headers — declaring alone never fires anything. Read it before adding either.
|
|
35
35
|
<!-- /feature:api-resources -->
|
|
36
36
|
- `assembly-app-surfaces` — REQUIRED before writing `.assembly/app.json`: declare which surfaces (dashboard, client portal, notification) the app exposes; omitting the client surface keeps the app off client portal nav entirely
|
|
37
37
|
- `assembly-app-bridge-header` — breadcrumbs (don't include app name), CTAs
|
|
@@ -112,7 +112,7 @@ The app should **detect the viewer type from the token** and render audience-app
|
|
|
112
112
|
|
|
113
113
|
**The CRM tab is never the only path to per-client data.** The CRM tab is a shortcut: it renders with a specific `clientId`/`companyId` already in the token. But any client/company-specific data or action it exposes must *also* be reachable from the Dashboard main view — even if it takes a few clicks. Build the main view so an internal user can browse to a specific client/company (a list, or a `UserCompanySelector` that drills into a per-entity detail screen) and reach the same data and actions there. The main-view detail screen doesn't have to match the CRM-tab layout — it can reuse the same components or present them differently — but the main view must never be empty or aggregate-only when the CRM tab has per-client functionality. Don't leave `internalUrl` as a dead end.
|
|
114
114
|
|
|
115
|
-
**More generally: every page you create needs a click path.** Next.js builds and typechecks a page nothing links to, so an orphaned route ships silently. Before a page is done, something on an already-reachable page must point at it — a `<Link>`, `router.push`, redirect, header CTA, or actions-menu item. `bun run build` enforces this (
|
|
115
|
+
**More generally: every page you create needs a click path.** Next.js builds and typechecks a page nothing links to, so an orphaned route ships silently. Before a page is done, something on an already-reachable page must point at it — a `<Link>`, `router.push`, redirect, header CTA, or actions-menu item. `bun run build` enforces this (`bun run check:route-reachability` runs it alone). When a primary flow depends on managing another resource first (cases need stages, documents need templates), ship that manager's entry point together with the dependent flow — an actions-menu item on the main view plus a link on the empty state that blocks on it.
|
|
116
116
|
|
|
117
117
|
### Per-client app visibility — platform setting, not the app's job
|
|
118
118
|
|
|
@@ -242,7 +242,7 @@ Two paths, decided by whether an `assembly-integrations` skill is present and, i
|
|
|
242
242
|
- `label` — short label shown above the input field.
|
|
243
243
|
- `description` — 1–2 sentences telling the user where to obtain the value.
|
|
244
244
|
- `type` — `url | api_key | secret | text`. The platform picks input style from this.
|
|
245
|
-
- `docsUrl` — optional link the platform renders as "Where do I find this?".
|
|
245
|
+
- `docsUrl` — optional link the platform renders as "Where do I find this?". Verify it resolves (`curl -sIL <url>`, expect a 2xx — follow redirects, or a valid redirecting doc URL reads as broken) before including it — vendor docs get restructured and a stale URL renders as a dead link in the setup flow.
|
|
246
246
|
|
|
247
247
|
<!-- !feature:notification-settings -->
|
|
248
248
|
Valid top-level manifest keys: `surfaces`, `requiredSecrets`, and `requiredIntegrations` (leave it in place if present — it is platform-managed).
|
|
@@ -733,7 +733,7 @@ import { requireAuth, requireCompanyScope } from '@assembly-js/app-foundation/se
|
|
|
733
733
|
|
|
734
734
|
**`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.
|
|
735
735
|
|
|
736
|
-
**`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.
|
|
736
|
+
**`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`.
|
|
737
737
|
|
|
738
738
|
```ts
|
|
739
739
|
// app/api/items/[id]/route.ts
|
|
@@ -776,11 +776,6 @@ export async function GET(
|
|
|
776
776
|
assembly,
|
|
777
777
|
);
|
|
778
778
|
if (scopeError) return scopeError;
|
|
779
|
-
// The guard checked the company leg only — a client-owned row must also be
|
|
780
|
-
// this viewer's, or any client in the company could read it.
|
|
781
|
-
if (item.clientId && auth.clientId && item.clientId !== auth.clientId) {
|
|
782
|
-
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
783
|
-
}
|
|
784
779
|
|
|
785
780
|
return NextResponse.json(item);
|
|
786
781
|
} catch (err) {
|
package/dist/assembly.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { assemblyApi } from '@assembly-js/node-sdk';
|
|
2
2
|
import { cookies, headers } from 'next/headers';
|
|
3
3
|
import { NextResponse } from 'next/server';
|
|
4
|
-
import { BEARER_COOKIE_NAME, BEARER_HEADER_NAME, decodeBearer, isSessionExchangeEnabled, } from './session-exchange.js';
|
|
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
6
|
// Trusted API origin for this stack, supplied by the build environment — NOT by
|
|
7
7
|
// a token claim. Empty in prod/shared-staging (the SDK's env default is already
|
|
@@ -148,7 +148,7 @@ export async function getAssembly(input) {
|
|
|
148
148
|
}
|
|
149
149
|
// Duplicated from lib/auth.ts to avoid a circular import.
|
|
150
150
|
function tokenExpiredResponse() {
|
|
151
|
-
return NextResponse.json({ error: 'Unauthorized', code: 'token_expired' }, { status: 401, headers: {
|
|
151
|
+
return NextResponse.json({ error: 'Unauthorized', code: 'token_expired' }, { status: 401, headers: { [TOKEN_EXPIRED_HEADER]: '1' } });
|
|
152
152
|
}
|
|
153
153
|
// Builds a bearer-only SDK client. The SDK's native `bearer` option sends
|
|
154
154
|
// Authorization: Bearer on every request — generated service methods and fetch
|
package/dist/auth.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
2
|
import { getAssembly } from './assembly.js';
|
|
3
|
-
import { BEARER_COOKIE_NAME, BEARER_HEADER_NAME, decodeBearer, isSessionExchangeEnabled, } from './session-exchange.js';
|
|
3
|
+
import { BEARER_COOKIE_NAME, BEARER_HEADER_NAME, decodeBearer, isSessionExchangeEnabled, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
|
|
4
4
|
// Detect an expired/undecryptable-token error from the SDK by shape rather than
|
|
5
5
|
// by importing a concrete class — the foundation builds against whatever SDK
|
|
6
6
|
// version is published, which may not export the error type.
|
|
@@ -11,7 +11,7 @@ function isTokenInvalid(err) {
|
|
|
11
11
|
return e.name === 'TokenInvalidError' || e.code === 'TOKEN_INVALID';
|
|
12
12
|
}
|
|
13
13
|
function tokenExpiredResponse() {
|
|
14
|
-
return NextResponse.json({ error: 'Unauthorized', code: 'token_expired' }, { status: 401, headers: {
|
|
14
|
+
return NextResponse.json({ error: 'Unauthorized', code: 'token_expired' }, { status: 401, headers: { [TOKEN_EXPIRED_HEADER]: '1' } });
|
|
15
15
|
}
|
|
16
16
|
// Resolves the current viewer's identity for a route handler.
|
|
17
17
|
// Exchange on: bearer-only — decodes the bearer (proxy-forwarded header on first
|
|
@@ -60,13 +60,15 @@ export async function requireAuth(req) {
|
|
|
60
60
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
-
// Asserts the viewer can access a resource scoped to a company
|
|
64
|
-
// Internal users always pass. For a company-scoped resource (companyId
|
|
65
|
-
// clients must belong to that company: if payload.companyId is present it
|
|
66
|
-
// match; otherwise membership is confirmed by fetching the client record.
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
63
|
+
// Asserts the viewer can access a resource scoped to a company, to a client, or
|
|
64
|
+
// to both. Internal users always pass. For a company-scoped resource (companyId
|
|
65
|
+
// set), clients must belong to that company: if payload.companyId is present it
|
|
66
|
+
// must match; otherwise membership is confirmed by fetching the client record.
|
|
67
|
+
// Whenever the resource names a clientId it must also be the viewer's, so a row
|
|
68
|
+
// carrying both columns is authorized on the intersection rather than on company
|
|
69
|
+
// alone — otherwise any client in the company could reach a sibling's row.
|
|
70
|
+
// Always pass both scope columns: without clientId a client-scoped record
|
|
71
|
+
// (companyId null) 404s for the very client it is assigned to.
|
|
70
72
|
// Pass a session token string (legacy path) or a bearer-authenticated AssemblyAPI
|
|
71
73
|
// instance from getAssemblyForRequest (bearer path) to enable the membership lookup.
|
|
72
74
|
// Returns null on success, or a 403/404 NextResponse on mismatch.
|
|
@@ -82,12 +84,20 @@ export async function requireCompanyScope(payload, resource, tokenOrAssembly) {
|
|
|
82
84
|
? null
|
|
83
85
|
: NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
84
86
|
}
|
|
87
|
+
// A row carrying both columns belongs to one client inside the company, so the
|
|
88
|
+
// company match alone would let any sibling client in it through.
|
|
89
|
+
const clientMatches = !resource.clientId || resource.clientId === payload.clientId;
|
|
85
90
|
if (payload.companyId) {
|
|
86
|
-
return payload.companyId === resource.companyId
|
|
91
|
+
return payload.companyId === resource.companyId && clientMatches
|
|
87
92
|
? null
|
|
88
93
|
: NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
89
94
|
}
|
|
90
95
|
// No companyId in payload: client has multiple companies, verify membership.
|
|
96
|
+
// Ownership is decided from the token first so a sibling client fails here
|
|
97
|
+
// rather than after a round-trip that could only widen the answer.
|
|
98
|
+
if (!clientMatches) {
|
|
99
|
+
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
100
|
+
}
|
|
91
101
|
if (!tokenOrAssembly) {
|
|
92
102
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
93
103
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
// Browser-side bearer renewal for generated apps. Exported via the dedicated
|
|
3
|
+
// `@assembly-js/app-foundation/lib-client` subpath rather than the ./client
|
|
4
|
+
// barrel: this module imports the app bridge, which is browser-only and must
|
|
5
|
+
// never be dragged into the Edge middleware bundle that imports ./client
|
|
6
|
+
// (proxy.ts pulls constants from that barrel).
|
|
7
|
+
import { AssemblyBridge } from '@assembly-js/app-bridge';
|
|
8
|
+
import { APP_CSRF_HEADER, TOKEN_EXPIRED_HEADER } from './session-exchange.js';
|
|
9
|
+
const REEXCHANGE_PATH = '/api/assembly-reexchange';
|
|
10
|
+
// Single-flight gate for renewals. Session tokens are single-use for exchange:
|
|
11
|
+
// when the 60-minute bearer expires, every in-flight fetch on the page 401s at
|
|
12
|
+
// once, and uncoordinated renewals would race each other for the token — the
|
|
13
|
+
// first mints the bearer and the losers surface their original 401 even though
|
|
14
|
+
// a valid bearer was just installed. All concurrent callers share this one
|
|
15
|
+
// promise instead; it is cleared on settle so a later expiry renews afresh.
|
|
16
|
+
let renewalInFlight = null;
|
|
17
|
+
// Silently renews the bearer cookie after a 60-minute expiry. Asks the bridge
|
|
18
|
+
// for the freshest session token (the parent keeps it alive) and posts it to the
|
|
19
|
+
// re-exchange route, which mints a new bearer and sets the HttpOnly cookie — no
|
|
20
|
+
// page reload. Returns true iff a fresh bearer was set. This is the only place
|
|
21
|
+
// app code touches the raw session token, and only to re-mint; it is never sent
|
|
22
|
+
// to the platform API.
|
|
23
|
+
export function reexchangeBearer() {
|
|
24
|
+
if (renewalInFlight)
|
|
25
|
+
return renewalInFlight;
|
|
26
|
+
const renewal = renewBearer().finally(() => {
|
|
27
|
+
renewalInFlight = null;
|
|
28
|
+
});
|
|
29
|
+
renewalInFlight = renewal;
|
|
30
|
+
return renewal;
|
|
31
|
+
}
|
|
32
|
+
async function renewBearer() {
|
|
33
|
+
let token = AssemblyBridge.sessionToken.getCurrent()?.token ?? '';
|
|
34
|
+
// Proactively refresh: we're recovering from an expiry, so avoid re-minting
|
|
35
|
+
// from a session token that may itself be stale.
|
|
36
|
+
try {
|
|
37
|
+
const refreshed = await AssemblyBridge.sessionToken.refresh();
|
|
38
|
+
token = refreshed?.token ?? token;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Ignore: fall back to the current token below.
|
|
42
|
+
}
|
|
43
|
+
if (!token)
|
|
44
|
+
return false;
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetch(REEXCHANGE_PATH, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'x-assembly-token': token, [APP_CSRF_HEADER]: '1' },
|
|
49
|
+
});
|
|
50
|
+
return res.ok;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Whether a denial is worth a renew-and-retry. A 401 is an authentication
|
|
57
|
+
// failure — overwhelmingly an expired bearer, and foundation helpers mark
|
|
58
|
+
// theirs with the expiry header — so it always renews; unmarked 401s renew too
|
|
59
|
+
// because deployed apps hand-roll bare 401s on expiry paths, and gating those
|
|
60
|
+
// would hard-fail their users at the 60-minute expiry. A 403 is an
|
|
61
|
+
// authorization denial (requireCompanyScope, admin gates) that a fresh bearer
|
|
62
|
+
// cannot fix, so it renews only when it explicitly carries the expiry marker.
|
|
63
|
+
function shouldRenewBearer(res) {
|
|
64
|
+
if (res.status === 401)
|
|
65
|
+
return true;
|
|
66
|
+
return res.status === 403 && res.headers.has(TOKEN_EXPIRED_HEADER);
|
|
67
|
+
}
|
|
68
|
+
// fetch() for the app's own route handlers. The bearer travels automatically in
|
|
69
|
+
// the HttpOnly cookie (same-origin), so this adds only the CSRF header — never a
|
|
70
|
+
// token. On an expiry-shaped denial (see shouldRenewBearer) it renews the bearer
|
|
71
|
+
// once via reexchangeBearer and retries; a genuine forbidden just returns.
|
|
72
|
+
export async function authFetch(input, init) {
|
|
73
|
+
const headers = { ...(init?.headers ?? {}), [APP_CSRF_HEADER]: '1' };
|
|
74
|
+
const res = await fetch(input, { ...init, headers });
|
|
75
|
+
if (!shouldRenewBearer(res)) {
|
|
76
|
+
return res;
|
|
77
|
+
}
|
|
78
|
+
const renewed = await reexchangeBearer();
|
|
79
|
+
if (!renewed) {
|
|
80
|
+
return res;
|
|
81
|
+
}
|
|
82
|
+
return fetch(input, { ...init, headers });
|
|
83
|
+
}
|
package/dist/mint-bearer.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ExchangedSession, type MintFailure } from './session-exchange.js';
|
|
1
|
+
import { type ExchangedSession, type ExchangeRejectedViewer, type MintFailure } from './session-exchange.js';
|
|
2
2
|
export interface MintedBearer {
|
|
3
3
|
bearer: string;
|
|
4
4
|
bearerExpiresAt: number;
|
|
@@ -9,8 +9,18 @@ export type MintOutcome = ({
|
|
|
9
9
|
} & MintedBearer) | {
|
|
10
10
|
ok: false;
|
|
11
11
|
reason: MintFailure;
|
|
12
|
+
/** On a 'rejected' replay, whether the viewer who consumed the token
|
|
13
|
+
* matches the caller's cookie session (only when the caller passed
|
|
14
|
+
* cookieSessionId; absent on legacy rejections). */
|
|
15
|
+
rejectedViewer?: ExchangeRejectedViewer;
|
|
12
16
|
};
|
|
13
|
-
export
|
|
17
|
+
export interface MintSessionOptions {
|
|
18
|
+
/** Session id behind the bearer the caller already holds. Forwarded with the
|
|
19
|
+
* exchange so a rejected replay can say whether the consumer was the same
|
|
20
|
+
* viewer (see MintOutcome.rejectedViewer). */
|
|
21
|
+
cookieSessionId?: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function mintBearerFromSessionToken(token: string, options?: MintSessionOptions): Promise<MintOutcome>;
|
|
14
24
|
export interface MintedAppBearer {
|
|
15
25
|
bearer: string;
|
|
16
26
|
bearerExpiresAt: number;
|
package/dist/mint-bearer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { assemblyApi, OpenAPI } from '@assembly-js/node-sdk';
|
|
2
|
-
import { BEARER_TTL_MS, classifyExchangeFailure, } from './session-exchange.js';
|
|
2
|
+
import { BEARER_TTL_MS, classifyExchangeFailure, EXCHANGE_REJECTION_HEADER, parseExchangeRejection, } from './session-exchange.js';
|
|
3
3
|
// Trusted API origin for this stack (build-env supplied); empty in prod where
|
|
4
4
|
// the SDK default is correct.
|
|
5
5
|
const mintBaseUrl = process.env.ASSEMBLY_API_BASE_URL || undefined;
|
|
@@ -20,7 +20,7 @@ const EXCHANGE_TIMEOUT_MS = 15_000;
|
|
|
20
20
|
//
|
|
21
21
|
// On failure returns ok:false with a reason (see MintFailure) so the caller can
|
|
22
22
|
// tell a spent token from an exchange it simply could not complete.
|
|
23
|
-
export async function mintBearerFromSessionToken(token) {
|
|
23
|
+
export async function mintBearerFromSessionToken(token, options) {
|
|
24
24
|
let assembly;
|
|
25
25
|
try {
|
|
26
26
|
assembly = await assemblyApi({
|
|
@@ -56,14 +56,22 @@ export async function mintBearerFromSessionToken(token) {
|
|
|
56
56
|
const resp = await assembly.fetch('/v1/session', {
|
|
57
57
|
method: 'POST',
|
|
58
58
|
headers: { 'Content-Type': 'application/json' },
|
|
59
|
-
body: JSON.stringify({
|
|
59
|
+
body: JSON.stringify({
|
|
60
|
+
sessionId: payload.tokenId,
|
|
61
|
+
...(options?.cookieSessionId
|
|
62
|
+
? { cookieSessionId: options.cookieSessionId }
|
|
63
|
+
: {}),
|
|
64
|
+
}),
|
|
60
65
|
signal: controller.signal,
|
|
61
66
|
});
|
|
62
67
|
if (!resp.ok) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
const reason = classifyExchangeFailure(resp.status);
|
|
69
|
+
// Only a definitive rejection can carry a viewer verdict; on
|
|
70
|
+
// 'unavailable' the token may be unspent and no comparison happened.
|
|
71
|
+
const rejectedViewer = reason === 'rejected'
|
|
72
|
+
? parseExchangeRejection(resp.headers.get(EXCHANGE_REJECTION_HEADER))
|
|
73
|
+
: undefined;
|
|
74
|
+
return { ok: false, reason, rejectedViewer };
|
|
67
75
|
}
|
|
68
76
|
// session.* mirrors platform-api's ExchangeResponse.Session — the
|
|
69
77
|
// authoritative server-side record backing the (possibly slim) token, so
|
|
@@ -5,6 +5,10 @@ export declare function classifyExchangeFailure(status: number): MintFailure;
|
|
|
5
5
|
export declare function serializeBearerCookie(value: string, maxAgeSeconds: number, partitioned: boolean): string;
|
|
6
6
|
export declare const BEARER_HEADER_NAME = "x-assembly-request-bearer";
|
|
7
7
|
export declare const APP_CSRF_HEADER = "x-assembly-app";
|
|
8
|
+
export declare const TOKEN_EXPIRED_HEADER = "x-assembly-token-expired";
|
|
9
|
+
export declare const EXCHANGE_REJECTION_HEADER = "x-assembly-exchange-rejection";
|
|
10
|
+
export type ExchangeRejectedViewer = 'same_viewer' | 'different_viewer';
|
|
11
|
+
export declare function parseExchangeRejection(value: string | null): ExchangeRejectedViewer | undefined;
|
|
8
12
|
export declare function isSessionExchangeEnabled(): boolean;
|
|
9
13
|
export interface ExchangedSession {
|
|
10
14
|
workspaceId: string;
|
package/dist/session-exchange.js
CHANGED
|
@@ -47,6 +47,22 @@ export const BEARER_HEADER_NAME = 'x-assembly-request-bearer';
|
|
|
47
47
|
// presence is sufficient: a cross-site request physically cannot carry a
|
|
48
48
|
// custom header without a CORS preflight, and this app grants none.
|
|
49
49
|
export const APP_CSRF_HEADER = 'x-assembly-app';
|
|
50
|
+
// Marker header on 401s that mean "the bearer expired — re-exchange and retry",
|
|
51
|
+
// emitted by requireAuth / getAssemblyForRequest / the re-exchange route. Its
|
|
52
|
+
// absence on a denial is what lets authFetch skip renewal for genuine
|
|
53
|
+
// authorization failures.
|
|
54
|
+
export const TOKEN_EXPIRED_HEADER = 'x-assembly-token-expired';
|
|
55
|
+
// Response header platform-api sets on a rejected exchange of an
|
|
56
|
+
// already-consumed session token, when the caller supplied its cookie
|
|
57
|
+
// session id for comparison: says whether the consumer was the same viewer.
|
|
58
|
+
export const EXCHANGE_REJECTION_HEADER = 'x-assembly-exchange-rejection';
|
|
59
|
+
// Narrows the raw header value; an absent or unrecognized value yields
|
|
60
|
+
// undefined, which callers treat as the legacy opaque rejection.
|
|
61
|
+
export function parseExchangeRejection(value) {
|
|
62
|
+
return value === 'same_viewer' || value === 'different_viewer'
|
|
63
|
+
? value
|
|
64
|
+
: undefined;
|
|
65
|
+
}
|
|
50
66
|
// Master switch for the AI-260 server-side token exchange (bearer cookie flow).
|
|
51
67
|
// Defaults OFF so the exchange stays dormant in every foundation snapshot:
|
|
52
68
|
// merging this code — or any coworker rebuilding the snapshot for unrelated
|
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.6",
|
|
4
4
|
"description": "Versioned foundation helpers and build context for Assembly generated apps",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
"./client": {
|
|
19
19
|
"types": "./dist/client.d.ts",
|
|
20
20
|
"default": "./dist/client.js"
|
|
21
|
+
},
|
|
22
|
+
"./lib-client": {
|
|
23
|
+
"types": "./dist/lib-client.d.ts",
|
|
24
|
+
"default": "./dist/lib-client.js"
|
|
21
25
|
}
|
|
22
26
|
},
|
|
23
27
|
"scripts": {
|
|
@@ -28,11 +32,13 @@
|
|
|
28
32
|
"verify:bin": "node scripts/verify-bin.mjs"
|
|
29
33
|
},
|
|
30
34
|
"peerDependencies": {
|
|
35
|
+
"@assembly-js/app-bridge": ">=1.3.0",
|
|
31
36
|
"@assembly-js/node-sdk": ">=4.2.3-beta.2",
|
|
32
37
|
"next": ">=15",
|
|
33
38
|
"server-only": "*"
|
|
34
39
|
},
|
|
35
40
|
"devDependencies": {
|
|
41
|
+
"@assembly-js/app-bridge": "1.3.0",
|
|
36
42
|
"@assembly-js/node-sdk": "4.2.3-beta.2",
|
|
37
43
|
"@types/bun": "^1.1.0",
|
|
38
44
|
"@types/node": "^22",
|
|
@@ -30,12 +30,12 @@ If you are unsure, default to **internal-only**. Do not add a client surface unl
|
|
|
30
30
|
- Generate the corresponding routes in your app code. E.g. if you declare `client.path = "/c"`, you must have a `/c` page in the app.
|
|
31
31
|
|
|
32
32
|
<!-- !feature:notification-settings -->
|
|
33
|
-
> **Every surface MUST be nested under `surfaces`.** A surface placed at the top level — e.g. `{ "surfaces": {...}, "notification": { "path": "/n" } }` — is silently dropped by the deploy parser, leaving that surface's URL empty and its notification links broken. The only valid top-level keys are `surfaces`, `requiredSecrets`, and `requiredIntegrations` (see below). The build validates this: `bun run build` runs
|
|
33
|
+
> **Every surface MUST be nested under `surfaces`.** A surface placed at the top level — e.g. `{ "surfaces": {...}, "notification": { "path": "/n" } }` — is silently dropped by the deploy parser, leaving that surface's URL empty and its notification links broken. The only valid top-level keys are `surfaces`, `requiredSecrets`, and `requiredIntegrations` (see below). The build validates this: `bun run build` runs the manifest validator first and **fails with a specific error** if the manifest is malformed or a key is misplaced. Run `bun run validate:manifest` to check it directly.
|
|
34
34
|
|
|
35
35
|
`requiredSecrets` and `requiredIntegrations` are **top-level siblings of `surfaces`**, not surfaces — see the secrets guidance and the `assembly-integrations` skill. The full manifest shape is:
|
|
36
36
|
<!-- /feature:notification-settings -->
|
|
37
37
|
<!-- feature:notification-settings -->
|
|
38
|
-
> **Every surface MUST be nested under `surfaces`.** A surface placed at the top level — e.g. `{ "surfaces": {...}, "notification": { "path": "/n" } }` — is silently dropped by the deploy parser, leaving that surface's URL empty and its notification links broken. The only valid top-level keys are `surfaces`, `requiredSecrets`, `notificationSettings`, and `requiredIntegrations` (see below). The build validates this: `bun run build` runs
|
|
38
|
+
> **Every surface MUST be nested under `surfaces`.** A surface placed at the top level — e.g. `{ "surfaces": {...}, "notification": { "path": "/n" } }` — is silently dropped by the deploy parser, leaving that surface's URL empty and its notification links broken. The only valid top-level keys are `surfaces`, `requiredSecrets`, `notificationSettings`, and `requiredIntegrations` (see below). The build validates this: `bun run build` runs the manifest validator first and **fails with a specific error** if the manifest is malformed or a key is misplaced. Run `bun run validate:manifest` to check it directly.
|
|
39
39
|
|
|
40
40
|
`requiredSecrets`, `notificationSettings`, and `requiredIntegrations` are **top-level siblings of `surfaces`**, not surfaces — see the `assembly-notifications` skill, the secrets guidance, and the `assembly-integrations` skill. The full manifest shape is:
|
|
41
41
|
<!-- /feature:notification-settings -->
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<!-- feature:api-resources -->
|
|
2
2
|
---
|
|
3
3
|
name: assembly-automations
|
|
4
|
-
description: Use when the request describes a workflow that integrates with the Assembly platform — messaging a client, updating a CRM custom field, sending a contract or form, sharing a file, anything landing on Assembly's own data rather than only the app's tables — and it should happen in reaction to something happening in the app rather than because the user pressed a button. "When a time entry is logged, message the contact" and "completing a project should update the client's status field" both qualify. Both halves are required, and the word "automation" is not: a reaction that only writes the app's own tables is ordinary app code, and so is a platform write the user triggers directly in the app UI. Also use when the request asks the app to fire webhooks, drive or react to another tool, or expose operations others can invoke. Declare those events and operations in .assembly/api-resources.json so they appear as automation triggers and actions, emit each declared event at runtime with assembly.
|
|
4
|
+
description: Use when the request describes a workflow that integrates with the Assembly platform — messaging a client, updating a CRM custom field, sending a contract or form, sharing a file, anything landing on Assembly's own data rather than only the app's tables — and it should happen in reaction to something happening in the app rather than because the user pressed a button. "When a time entry is logged, message the contact" and "completing a project should update the client's status field" both qualify. Both halves are required, and the word "automation" is not: a reaction that only writes the app's own tables is ordinary app code, and so is a platform write the user triggers directly in the app UI. Also use when the request asks the app to fire webhooks, drive or react to another tool, or expose operations others can invoke. Declare those events and operations in .assembly/api-resources.json so they appear as automation triggers and actions, emit each declared event at runtime with assembly.fetch, and serve each exposed operation with automationActionRoute. Covers the manifest schema, the two field shapes, event naming, the recipient-id contract, trigger configuration, dropdown option sources for action inputs, runtime event emission, and the action route contract.
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
An app's API contract — the events it emits and the operations workspace automations can invoke — is declared in **`.assembly/api-resources.json`** at the repo root. The platform reads it at publish and registers the app's triggers and actions. Which file owns what: `.assembly/app.json` = how the platform runs the app (surfaces, secrets, integrations); `.assembly/api-resources.json` = the API contract the app exposes. Never mix their keys.
|
|
8
8
|
|
|
9
|
-
Declaring a webhook only registers the trigger — the running app must also **emit** the event with `assembly.
|
|
9
|
+
Declaring a webhook only registers the trigger — the running app must also **emit** the event with `assembly.fetch('/v1/webhooks/<eventName>', …)` when it happens, or automations built on it never run. See "Emitting events at runtime" below.
|
|
10
10
|
|
|
11
11
|
## When to declare it
|
|
12
12
|
|
|
@@ -207,27 +207,52 @@ Laptop Desktop Mobile Print Qrcode BarcodeRead Language EarthAmericas
|
|
|
207
207
|
|
|
208
208
|
## Emitting events at runtime
|
|
209
209
|
|
|
210
|
-
Declaring a webhook only makes the trigger appear in the automations builder — **the platform never fires the event for you**. The app must report each event at the moment it happens, from the server-side code path that performs the state change
|
|
210
|
+
Declaring a webhook only makes the trigger appear in the automations builder — **the platform never fires the event for you**. The app must report each event at the moment it happens, from the server-side code path that performs the state change — and report **who acted** on the same call, because the platform sees only the app's own credentials and the app is the only thing that knows the person behind the event.
|
|
211
211
|
|
|
212
212
|
```ts
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
213
|
+
import { after } from 'next/server';
|
|
214
|
+
|
|
215
|
+
const viewer = await requireAuth(req);
|
|
216
|
+
if (viewer instanceof NextResponse) return viewer;
|
|
217
|
+
// … the write commits here …
|
|
218
|
+
|
|
219
|
+
// Read the request values now — after() runs once the response is already sent.
|
|
220
|
+
const actorId = viewer.internalUserId ?? viewer.clientId ?? '';
|
|
221
|
+
const clientIP = req.headers.get('x-forwarded-for') ?? '';
|
|
222
|
+
const userAgent = req.headers.get('user-agent') ?? '';
|
|
223
|
+
|
|
224
|
+
after(async () => {
|
|
225
|
+
try {
|
|
226
|
+
const res = await assembly.fetch('/v1/webhooks/timeEntry.created', {
|
|
227
|
+
method: 'POST',
|
|
228
|
+
headers: {
|
|
229
|
+
'X-Assembly-Actor-Id': actorId,
|
|
230
|
+
'X-Assembly-Source': 'web',
|
|
231
|
+
'X-Assembly-Client-IP': clientIP,
|
|
232
|
+
'X-Assembly-User-Agent': userAgent,
|
|
233
|
+
},
|
|
234
|
+
body: JSON.stringify({ id: entry.id, clientId: entry.clientId, minutes: entry.minutes }),
|
|
235
|
+
});
|
|
236
|
+
if (!res.ok) {
|
|
237
|
+
console.error(`timeEntry.created webhook failed: ${res.status} ${await res.text()}`);
|
|
238
|
+
}
|
|
239
|
+
} catch (err) {
|
|
240
|
+
console.error('timeEntry.created webhook failed:', err);
|
|
241
|
+
}
|
|
219
242
|
});
|
|
220
243
|
```
|
|
221
244
|
|
|
222
|
-
- `
|
|
223
|
-
-
|
|
245
|
+
- **`assembly.fetch` is how an event is emitted** — it adds the app's auth and the JSON content type for you. The path is `/v1/webhooks/<eventName>` using the exact `eventName` from this file; the platform validates the name and payload against this manifest's registration, then runs matching automations.
|
|
246
|
+
- **Check `res.ok`.** `assembly.fetch` resolves on a 4xx instead of throwing, so an unregistered event name or an invalid payload is a silent no-op unless you read the status — the easiest way there is to ship a trigger that never fires.
|
|
247
|
+
- **Emit inside `after()`, and never leave the call un-awaited.** `after()` runs the emit once the response is sent, so it never delays or breaks the user's action — log the failure there and continue. Dropping the `await` to keep the handler fast looks equivalent and is not: a floating promise is frozen when the handler returns, so the event arrives minutes late or never, and which one you get is luck.
|
|
248
|
+
- **Capture the request values in the handler, not inside the callback.** `after()` runs past the response, so read `actorId` / IP / user-agent before it and close over them.
|
|
249
|
+
- Emit **after** the database write commits, never before — an event emitted for a rolled-back write is a phantom trigger.
|
|
224
250
|
- The payload is the resource's record `schema[]` shape: required fields present, `fieldName`s exact. Always include the recipient fields the record declares (`clientId` / `companyId` / `internalUserId`) — automations resolve who to act on from this payload, and a missing recipient silently skips the run.
|
|
225
|
-
- Emission must never break the user's action: wrap the call in try/catch, log the failure, and continue.
|
|
226
251
|
- **Every webhook declared in this file needs at least one emit call site in the app.** A declared-but-never-emitted event is an incomplete feature: the trigger shows up in the automations UI, workspace admins build automations on it, and nothing ever runs.
|
|
227
252
|
|
|
228
253
|
### Reporting who acted
|
|
229
254
|
|
|
230
|
-
Every event also lands in the workspace's audit log, where a workspace admin reads who did what. The
|
|
255
|
+
Every event also lands in the workspace's audit log, where a workspace admin reads who did what. The four headers above are what that entry is built from:
|
|
231
256
|
|
|
232
257
|
| Header | Value |
|
|
233
258
|
|---|---|
|
|
@@ -236,28 +261,9 @@ Every event also lands in the workspace's audit log, where a workspace admin rea
|
|
|
236
261
|
| `X-Assembly-Client-IP` | the request's `x-forwarded-for` |
|
|
237
262
|
| `X-Assembly-User-Agent` | the request's `user-agent` |
|
|
238
263
|
|
|
239
|
-
`sendWebhook` cannot carry headers, so a user-triggered event is emitted through `assembly.fetch`, which adds the app's auth for you:
|
|
240
|
-
|
|
241
|
-
```ts
|
|
242
|
-
const viewer = await requireAuth(req);
|
|
243
|
-
if (viewer instanceof NextResponse) return viewer;
|
|
244
|
-
// … the write commits here …
|
|
245
|
-
|
|
246
|
-
await assembly.fetch('/v1/webhooks/timeEntry.created', {
|
|
247
|
-
method: 'POST',
|
|
248
|
-
headers: {
|
|
249
|
-
'X-Assembly-Actor-Id': viewer.internalUserId ?? viewer.clientId ?? '',
|
|
250
|
-
'X-Assembly-Source': 'web',
|
|
251
|
-
'X-Assembly-Client-IP': req.headers.get('x-forwarded-for') ?? '',
|
|
252
|
-
'X-Assembly-User-Agent': req.headers.get('user-agent') ?? '',
|
|
253
|
-
},
|
|
254
|
-
body: JSON.stringify({ id: entry.id, clientId: entry.clientId, minutes: entry.minutes }),
|
|
255
|
-
});
|
|
256
|
-
```
|
|
257
|
-
|
|
258
264
|
- **Report only what the request actually carries.** The platform records what it is given and infers nothing; an empty header is the same as no header.
|
|
259
265
|
- **Never pass an app-internal id as the actor.** The id is recorded only once it resolves to a user of that workspace, so an app's own row id attributes the event to nobody and loses the actor entirely.
|
|
260
|
-
- A background job has no caller
|
|
266
|
+
- A background job has no caller and no request scope: emit with `getOfflineAssembly()`, send no actor headers, and `await` the call directly — `after()` is unavailable there. The entry reads as a system event.
|
|
261
267
|
|
|
262
268
|
## Serving actions
|
|
263
269
|
|
|
@@ -423,8 +429,10 @@ export const POST = automationActionRoute<{
|
|
|
423
429
|
|
|
424
430
|
| Symptom | Cause | Fix |
|
|
425
431
|
| --- | --- | --- |
|
|
426
|
-
|
|
|
427
|
-
|
|
|
432
|
+
| The automation never runs, or runs minutes late | The emit is un-awaited, so the handler returned and the runtime froze it before it left the app | Wrap the emit in `after()` — see "Emitting events at runtime" |
|
|
433
|
+
| The automation never runs and the app logged nothing | The emit came back 4xx and nothing read `res.ok` — `assembly.fetch` resolves on an error status instead of throwing | Log a non-ok response, then fix whichever cause the body names |
|
|
434
|
+
| The emit returns 400 | The event name isn't registered for this app — a typo, or the manifest hasn't been published since the event was added | Publish, then emit the exact `eventName` from this file |
|
|
435
|
+
| 400 on an event name that is registered | The payload doesn't satisfy the record's `schema[]` — a required field is absent, or carries `""` / `0` / `false` | Send every required field with a real value; declare zeroable ones `optional: true` |
|
|
428
436
|
| Action returns 400 naming a field | The helper rejected the body before the handler ran: the automation's config never sends a field this manifest declares required | Make the field `automationSettings.isEnabled` so admins can fill it, or declare it `optional: true` |
|
|
429
437
|
| Action step fails with no app logs at all | The app is unreachable, or nothing serves the record's `baseUrl` | Serve a real route at exactly that path — the platform calls `baseUrl`, not `apis[].path` |
|
|
430
438
|
| Action returns 500 "Action is not registered" | `automationActionRoute`'s first argument doesn't match any `apis[].name` | Copy the name from this file verbatim |
|
|
@@ -112,7 +112,7 @@ That reads as: this client's rows **within the active company**, plus the compan
|
|
|
112
112
|
|
|
113
113
|
**On writes, validate the target company.** Before storing a row under a `companyId` the request supplied rather than the viewer's own, confirm it is actually one of the client's: `client.companyIds.includes(companyId)`. Otherwise a client can write into a company they don't belong to.
|
|
114
114
|
|
|
115
|
-
**On `[id]` route handlers,
|
|
115
|
+
**On `[id]` route handlers, pass both scope columns to `requireCompanyScope`.** It authorizes the intersection, so a row that also carries a `clientId` is reachable only by that client — never by a sibling in the same company. See `assembly-route-auth`.
|
|
116
116
|
|
|
117
117
|
## Pattern 3 — the placeholder pitfall
|
|
118
118
|
|
|
@@ -257,7 +257,7 @@ Include in `deliveryTargets` every surface the setting declares; the platform re
|
|
|
257
257
|
|
|
258
258
|
If `tsc` rejects `notificationSettingId` (an older scaffold's SDK version), run `bun add @assembly-js/node-sdk@latest` and re-check — the API accepts the field regardless.
|
|
259
259
|
|
|
260
|
-
If `bun run build` fails with an unknown-top-level-key error on `notificationSettings`, the
|
|
260
|
+
If `bun run build` fails with an unknown-top-level-key error on `notificationSettings`, the validator this app runs predates the key. `package.json`'s `validate:manifest` script names the copy it runs: when that is `.assembly/validate-manifest.mjs`, add `'notificationSettings'` to its `TOP_LEVEL_KEYS` and rerun. When it resolves out of `node_modules`, the app's foundation dependency is behind — update it rather than editing an installed file.
|
|
261
261
|
|
|
262
262
|
## App-level action label (Client Home)
|
|
263
263
|
|
|
@@ -18,33 +18,14 @@ import { requireAuth, requireCompanyScope, handleRouteError } from '@assembly-js
|
|
|
18
18
|
- Internal users always pass (client-vs-staff isolation only — **not** Client Access; limited internal users can still read another company's row unless you filter app-owned queries and lists yourself; see `assembly-internal-users`).
|
|
19
19
|
- For a **company-scoped** resource (`companyId` set), 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.
|
|
20
20
|
- For a **client-scoped** resource (`companyId` null), the helper matches `resource.clientId` against `identity.clientId`.
|
|
21
|
-
-
|
|
21
|
+
- When a row carries **both** columns the helper requires both to match, so the company leg alone never admits a sibling client.
|
|
22
22
|
- Returns `null` on success, or a 403/404 `NextResponse` on mismatch. A 403 is a permanent authorization failure — a re-exchange will not fix cross-tenant access.
|
|
23
23
|
|
|
24
24
|
The `resource` argument is `{ companyId: string | null; clientId?: string | null }` — the same shape you already loaded from the database. **Always select and pass both columns.** Omitting `clientId` makes every client-scoped record 404 for the very client it is assigned to, and a table with no `clientId` column cannot express client scope at all — so give records both a `companyId` and a `clientId` column in the schema, even when only one is populated for a given row (see `assembly-clients-companies`).
|
|
25
25
|
|
|
26
|
-
###
|
|
26
|
+
### Client-owned rows are covered by the guard
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
So for a **client-owned row** — one carrying both a `companyId` and a `clientId`, which is the shape `assembly-clients-companies` prescribes — `requireCompanyScope` alone lets **any client in that company** read or mutate it. Add the ownership check in the handler, right after the scope guard:
|
|
31
|
-
|
|
32
|
-
```ts
|
|
33
|
-
const scopeError = await requireCompanyScope(
|
|
34
|
-
auth,
|
|
35
|
-
{ companyId: item.companyId, clientId: item.clientId },
|
|
36
|
-
assembly,
|
|
37
|
-
);
|
|
38
|
-
if (scopeError) return scopeError;
|
|
39
|
-
|
|
40
|
-
// requireCompanyScope only checked the company leg. A row owned by a specific
|
|
41
|
-
// client must also belong to this viewer, or any client in the company can read it.
|
|
42
|
-
if (item.clientId && auth.clientId && item.clientId !== auth.clientId) {
|
|
43
|
-
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
44
|
-
}
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
Guard on `auth.clientId` being present so internal users (who legitimately see every row in the workspace) still pass. A company-wide row (`clientId` null) skips the check, as it should.
|
|
28
|
+
A **client-owned row** — one carrying both a `companyId` and a `clientId`, the shape `assembly-clients-companies` prescribes — is authorized on the intersection: a sibling client in the same company gets a 404. Pass both columns and add no ownership check of your own. Internal users still pass, and a company-wide row (`clientId` null) is reachable by every client in the company, as it should be.
|
|
48
29
|
|
|
49
30
|
For safe catch blocks and validating values before typed DB lookups (e.g. `parseUuid` before a uuid-column query), see `assembly-route-errors`. Do not treat every `[id]` route param as a UUID — only validate as UUID when the next step is a uuid-column lookup.
|
|
50
31
|
|
|
@@ -91,11 +72,6 @@ export async function GET(
|
|
|
91
72
|
assembly,
|
|
92
73
|
);
|
|
93
74
|
if (scopeError) return scopeError;
|
|
94
|
-
// The guard checked the company leg only — a client-owned row must also be
|
|
95
|
-
// this viewer's, or any client in the company could read it.
|
|
96
|
-
if (item.clientId && auth.clientId && item.clientId !== auth.clientId) {
|
|
97
|
-
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
98
|
-
}
|
|
99
75
|
|
|
100
76
|
return NextResponse.json(item);
|
|
101
77
|
} catch (err) {
|
|
@@ -133,9 +109,6 @@ export async function PATCH(
|
|
|
133
109
|
assembly,
|
|
134
110
|
);
|
|
135
111
|
if (scopeError) return scopeError;
|
|
136
|
-
if (item.clientId && auth.clientId && item.clientId !== auth.clientId) {
|
|
137
|
-
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
138
|
-
}
|
|
139
112
|
|
|
140
113
|
const body = await request.json();
|
|
141
114
|
// Never spread the full body — callers could include companyId or id
|
|
@@ -195,9 +168,6 @@ export async function GET(
|
|
|
195
168
|
assembly,
|
|
196
169
|
);
|
|
197
170
|
if (scopeError) return scopeError;
|
|
198
|
-
if (highlight.clientId && auth.clientId && highlight.clientId !== auth.clientId) {
|
|
199
|
-
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
200
|
-
}
|
|
201
171
|
|
|
202
172
|
return NextResponse.json(highlight);
|
|
203
173
|
} catch (err) {
|
|
@@ -217,7 +187,6 @@ Every `[id]`-nested route handler — regardless of HTTP method or nesting depth
|
|
|
217
187
|
3. Return 404 if not found.
|
|
218
188
|
4. `const assembly = await getAssemblyForRequest(request); if (assembly instanceof NextResponse) return assembly;`
|
|
219
189
|
5. `const scopeError = await requireCompanyScope(auth, { companyId: resource.companyId, clientId: resource.clientId }, assembly); if (scopeError) return scopeError;`
|
|
220
|
-
6.
|
|
221
|
-
7. Return data or perform the mutation.
|
|
190
|
+
6. Return data or perform the mutation.
|
|
222
191
|
|
|
223
|
-
Never skip steps 1
|
|
192
|
+
Never skip steps 1 or 5, even on GET handlers.
|