@assembly-js/app-foundation 0.0.5 → 0.0.7
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 +1 -6
- 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-bridge-header/SKILL.md +1 -1
- package/skills/assembly-automations/SKILL.md +2 -2
- package/skills/assembly-clients-companies/SKILL.md +1 -1
- package/skills/assembly-route-auth/SKILL.md +5 -36
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.
|
|
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.7",
|
|
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",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: assembly-app-bridge-header
|
|
3
|
-
description: Use any time the app sets breadcrumbs, primary CTA, secondary CTA, or actions menu in the Assembly header chrome. Always drive the header through the useAssemblyHeader hook — never call AssemblyBridge.header.set* from your own useEffect. Critical
|
|
3
|
+
description: Use any time the app sets breadcrumbs, primary CTA, secondary CTA, or actions menu in the Assembly header chrome. Always drive the header through the useAssemblyHeader hook — never call AssemblyBridge.header.set* from your own useEffect. Critical — the parent automatically prepends the app's name as breadcrumb 0; never include it yourself, or it renders twice.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
The Assembly header (breadcrumbs, primary/secondary CTA buttons, overflow actions menu) is controlled from your app. **Drive it with the `useAssemblyHeader` hook** — it ships in the foundation at `app/components/use-assembly-header.ts`.
|
|
@@ -1,7 +1,7 @@
|
|
|
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
|
|
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.
|
|
@@ -484,7 +484,7 @@ export const GET = automationActionRoute(async (_input, { workspaceId }) => {
|
|
|
484
484
|
|
|
485
485
|
**Call it an automation, not a webhook.** In the product this feature is Automations; "webhook" is the wire format underneath it. `sendWebhook` and the `webhooks` manifest key keep their names in code — this is about what you say in chat. "I added a *Time entry logged* trigger you can build automations on" lands; "I set up a webhook" reads as plumbing nobody asked for.
|
|
486
486
|
|
|
487
|
-
**Say what is left for them to do.** Declaring a trigger does not create the automation — the workspace still has to build it. When you finish, name the trigger you registered, name **each** automation they need to build, and
|
|
487
|
+
**Say what is left for them to do.** Declaring a trigger does not create the automation — the workspace still has to build it. When you finish, name the trigger you registered, name **each** automation they need to build, and hyperlink them to the **Automations** module at `https://dashboard.assembly.com/settings/automations`. Count the actions the request asked for, not the triggers: "completing a project should message the client and update their status field" is one trigger and two actions, so say both. Skip this and the app looks like it ignored the request: the trigger exists, nothing is wired to it, and nothing happens.
|
|
488
488
|
|
|
489
489
|
## Validation
|
|
490
490
|
|
|
@@ -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
|
|
|
@@ -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.
|