@assembly-js/app-foundation 0.0.5 → 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 CHANGED
@@ -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. **The two legs are either/or:** a non-null `resource.companyId` selects the company check and `clientId` is never compared, so a client-owned row (both columns set) needs an explicit ownership check in the handler after the guard — see `assembly-route-auth`.
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: { 'x-assembly-token-expired': '1' } });
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: { 'x-assembly-token-expired': '1' } });
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 OR to a client.
64
- // Internal users always pass. For a company-scoped resource (companyId set),
65
- // clients must belong to that company: if payload.companyId is present it must
66
- // match; otherwise membership is confirmed by fetching the client record.
67
- // For a client-scoped resource (companyId null), pass the resource's clientId
68
- // so the owning client can be matched against the viewer — without it a
69
- // client-scoped record 404s for the very client it is assigned to.
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,2 @@
1
+ export declare function reexchangeBearer(): Promise<boolean>;
2
+ export declare function authFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
@@ -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
+ }
@@ -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 declare function mintBearerFromSessionToken(token: string): Promise<MintOutcome>;
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;
@@ -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({ sessionId: payload.tokenId }),
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
- return {
64
- ok: false,
65
- reason: classifyExchangeFailure(resp.status),
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;
@@ -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.5",
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",
@@ -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, `requireCompanyScope` is not enough by itself.** It authorizes the *company* leg; for a row that also has a `clientId`, add the client-ownership check yourself. See `assembly-route-auth`.
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
 
@@ -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
- - Those two are **either/or** — it never checks both, which is why a client-owned row needs the extra check below.
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
- ### `requireCompanyScope` does not check client ownership on a company-scoped row
26
+ ### Client-owned rows are covered by the guard
27
27
 
28
- The two legs are **either/or**, not both: when `resource.companyId` is non-null the helper authorizes on the company match alone and never compares `clientId`. It only compares `clientId` when `resource.companyId` is null.
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. If the row is client-owned, check ownership — the guard above only covered the company leg: `if (resource.clientId && auth.clientId && resource.clientId !== auth.clientId) return NextResponse.json({ error: 'Not found' }, { status: 404 });`
221
- 7. Return data or perform the mutation.
190
+ 6. Return data or perform the mutation.
222
191
 
223
- Never skip steps 1, 5, or 6, even on GET handlers.
192
+ Never skip steps 1 or 5, even on GET handlers.