@assembly-js/app-foundation 0.0.0 → 0.0.1-rc.1
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 +1119 -0
- package/dist/api-errors.d.ts +5 -0
- package/dist/api-errors.js +33 -0
- package/dist/app-storage.d.ts +7 -0
- package/dist/app-storage.js +22 -0
- package/dist/assembly.d.ts +49 -0
- package/dist/assembly.js +235 -0
- package/dist/auth.d.ts +17 -0
- package/dist/auth.js +118 -0
- package/dist/client-companies.d.ts +16 -0
- package/dist/client-companies.js +23 -0
- package/dist/client.d.ts +5 -2
- package/dist/client.js +14 -3
- package/dist/labels.d.ts +9 -0
- package/dist/labels.js +17 -0
- package/dist/mint-bearer.d.ts +18 -0
- package/dist/mint-bearer.js +152 -0
- package/dist/nango.d.ts +174 -0
- package/dist/nango.js +430 -0
- package/dist/network-capture.d.ts +36 -0
- package/dist/network-capture.js +473 -0
- package/dist/params.d.ts +3 -0
- package/dist/params.js +20 -0
- package/dist/resource-resolution.d.ts +89 -0
- package/dist/resource-resolution.js +142 -0
- package/dist/server.d.ts +9 -2
- package/dist/server.js +15 -5
- package/dist/session-exchange.d.ts +20 -0
- package/dist/session-exchange.js +101 -0
- package/dist/theme.d.ts +5 -0
- package/dist/theme.js +20 -0
- package/package.json +9 -3
- package/skills/assembly-ai/SKILL.md +133 -0
- package/skills/assembly-app-bridge-header/SKILL.md +88 -0
- package/skills/assembly-app-bridge-toast/SKILL.md +79 -0
- package/skills/assembly-avatar-sub/SKILL.md +103 -0
- package/skills/assembly-client-users/SKILL.md +45 -0
- package/skills/assembly-clients-companies/SKILL.md +143 -0
- package/skills/assembly-companies/SKILL.md +45 -0
- package/skills/assembly-custom-fields/SKILL.md +86 -0
- package/skills/assembly-db-transactions/SKILL.md +79 -0
- package/skills/assembly-dev-server-logs/SKILL.md +26 -0
- package/skills/assembly-forms/SKILL.md +103 -0
- package/skills/assembly-internal-users/SKILL.md +69 -0
- package/skills/assembly-mutations/SKILL.md +58 -0
- package/skills/assembly-native-resources/SKILL.md +117 -0
- package/skills/assembly-route-auth/SKILL.md +223 -0
- package/skills/assembly-route-errors/SKILL.md +81 -0
- package/skills/assembly-semantic-colors/SKILL.md +76 -0
- package/skills/assembly-session-token/SKILL.md +153 -0
- package/skills/assembly-storage/SKILL.md +157 -0
- package/skills/assembly-user-company-selector/SKILL.md +98 -0
- package/skills/assembly-workspace-labels/SKILL.md +50 -0
- package/skills/clearing-client-notifications/SKILL.md +100 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: assembly-internal-users
|
|
3
|
+
description: Use when the app needs to read or list internal users, or when an internal viewer browses clients/companies in the Dashboard — staff (admins, agents, accountants, lawyers) running the Assembly workspace. Covers Client Access permissions (isClientAccessLimited, companyAccessList) and scoping internal views to the viewer's allowed companies. Internal users sign in to the Dashboard, never to the Client Portal. Detect via tokenPayload.internalUserId.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Internal users are the **business team** running the Assembly workspace. They sign into the Dashboard at the workspace's domain, never the branded Client Portal. They have full read/write power over clients, companies, files, etc. — **within their Client Access scope** (see below).
|
|
7
|
+
|
|
8
|
+
## Identify the current viewer
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
const { internalUserId, clientId } = await assembly.getTokenPayload();
|
|
12
|
+
const isInternal = !!internalUserId;
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
If `internalUserId` is set, the current viewer is staff. (`clientId` is set for client viewers — see `assembly-client-users` skill.)
|
|
16
|
+
|
|
17
|
+
## SDK methods
|
|
18
|
+
|
|
19
|
+
- `listInternalUsers({ nextToken, limit })` — paged list of all staff in the workspace
|
|
20
|
+
- `retrieveInternalUser({ id })` — single record by id
|
|
21
|
+
|
|
22
|
+
## Client Access permissions
|
|
23
|
+
|
|
24
|
+
Client Access is a **workspace-level** setting (configured in workspace settings, not inside your app). By default every internal user can see all clients and companies. Admins can limit an internal user to specific companies; that limit ripples through the platform.
|
|
25
|
+
|
|
26
|
+
On `retrieveInternalUser` / `listInternalUsers`, each internal user may carry:
|
|
27
|
+
|
|
28
|
+
- `isClientAccessLimited` — `false` or `null` ⇒ full access to every client/company; `true` ⇒ limited
|
|
29
|
+
- `companyAccessList` — when limited, the company IDs they can access (empty ⇒ no company access)
|
|
30
|
+
|
|
31
|
+
Access is **company-scoped**: a limited user can see a client iff **any** of the client's `companyIds` is in their `companyAccessList`. Every client belongs to at least one company (real or placeholder), so the company check covers all clients. This matches `users.User.IsAccessAllowed` in the backend.
|
|
32
|
+
|
|
33
|
+
The session token carries only `internalUserId` — not the access fields. Load the viewer's scope once per request when you need it:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
const identity = await getSessionIdentity();
|
|
37
|
+
if (!identity?.internalUserId) return; // not an internal viewer
|
|
38
|
+
|
|
39
|
+
const viewer = await assembly.retrieveInternalUser({ id: identity.internalUserId });
|
|
40
|
+
const isLimited = viewer.isClientAccessLimited === true;
|
|
41
|
+
const accessList = viewer.companyAccessList ?? [];
|
|
42
|
+
|
|
43
|
+
function canAccessCompany(companyId: string): boolean {
|
|
44
|
+
if (!isLimited) return true;
|
|
45
|
+
return accessList.includes(companyId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function canAccessClient(client: { companyIds?: string[] }): boolean {
|
|
49
|
+
if (!isLimited) return true;
|
|
50
|
+
return (client.companyIds ?? []).some((id) => accessList.includes(id));
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### What to scope
|
|
55
|
+
|
|
56
|
+
- **SDK lists and pickers** — `listClients`, `listCompanies`, and any UI built from them (tables, `UserCompanySelector` options) must only include clients/companies the viewer can access. Filter each page after fetch, or skip rows that fail `canAccessClient` / `canAccessCompany`. Do not assume the platform API filtered the list for you.
|
|
57
|
+
- **App-owned data** — queries against your Postgres tables must apply the same rule: when the viewer is limited, restrict rows to `companyId IN companyAccessList` (and for client-owned rows, ensure the client is accessible). `requireCompanyScope` lets every internal user through — it enforces client-vs-staff isolation, not Client Access. See `assembly-route-auth` and `assembly-clients-companies`.
|
|
58
|
+
- **CRM-tab context** — when the token already carries a `clientId` or `companyId`, the viewer was routed to a record they can access; scope to that tab as usual. The global Dashboard main view still needs the filters above.
|
|
59
|
+
- **Fan-out to other internal users** (notifications, assignments) — filter recipients by their access to the relevant client/company.
|
|
60
|
+
|
|
61
|
+
Workspace-wide surfaces with no client/company tie (e.g. "all staff" settings) still show all internal users; only **client/company data** is scoped.
|
|
62
|
+
|
|
63
|
+
## UI guidance
|
|
64
|
+
|
|
65
|
+
When the viewer is internal, the app is rendered inside the Dashboard. Show the **full-power staff UI** — create/edit/delete, bulk actions, exports, admin views — but **scoped to the viewer's Client Access**:
|
|
66
|
+
|
|
67
|
+
- Global views list only clients and companies they can access (not "every client in the workspace" unless they have full access).
|
|
68
|
+
- CRM-tab views inherit the tab's `clientId` / `companyId` from the token.
|
|
69
|
+
- Do not strip staff affordances to look like the client portal; do not show clients or companies outside their access list.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: assembly-mutations
|
|
3
|
+
description: Use whenever calling updateClient, updateCompany, updateNote, updateTask, or any other patch-style mutation. The requestBody must contain ONLY the fields actually changing — including unchanged fields like the deprecated companyId triggers re-assignment that 403s on client tokens.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Patch-style endpoints (`updateClient`, `updateCompany`, `updateNote`, `updateTask`, etc.) treat **every non-empty top-level field in `requestBody` as "set this to this value."** Including fields you happen to have on hand — from a `retrieve` response, props, or the token payload — re-assigns those fields, even when the user didn't change them.
|
|
7
|
+
|
|
8
|
+
## The most common bite: `companyId` on `updateClient`
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
// ❌ WRONG — re-assigns the client's company on every save.
|
|
12
|
+
// Client tokens cannot mutate companyId, so the entire call 403s
|
|
13
|
+
// with a permissions error that's confusing to debug.
|
|
14
|
+
await assembly.updateClient({
|
|
15
|
+
id: clientId,
|
|
16
|
+
requestBody: {
|
|
17
|
+
companyId, // ← from earlier retrieveClient
|
|
18
|
+
customFields: { [fieldKey]: [newValue] },
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// ✅ RIGHT — only the field actually being edited.
|
|
23
|
+
await assembly.updateClient({
|
|
24
|
+
id: clientId,
|
|
25
|
+
requestBody: {
|
|
26
|
+
customFields: { [fieldKey]: [newValue] },
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## The rule
|
|
32
|
+
|
|
33
|
+
`requestBody` for any patch endpoint should contain **only the fields the user is editing in this UI action.** Don't pass:
|
|
34
|
+
|
|
35
|
+
- Values from a `retrieve` call you read for display
|
|
36
|
+
- Values from the token payload (`clientId`, `companyId`, `companyIds`)
|
|
37
|
+
- Values you spread from props "for safety"
|
|
38
|
+
|
|
39
|
+
If the user changed name + email, send `{ givenName, familyName, email }` and nothing else. If they only changed a custom field, send only `customFields`.
|
|
40
|
+
|
|
41
|
+
## Endpoints this applies to
|
|
42
|
+
|
|
43
|
+
`updateClient`, `updateCompany`, `updateNote`, `updateTask`, and any other `update*` SDK method with a `requestBody` parameter.
|
|
44
|
+
|
|
45
|
+
## Bonus: surface the real API error in mutation UIs
|
|
46
|
+
|
|
47
|
+
When the mutation fails, the route handler returns the SDK's error message in the response body. Display it. Hardcoding `"Failed to save"` hides the actual cause (auth expiry, permission denial, validation, deprecated-field re-assignment, etc.).
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// ❌ WRONG
|
|
51
|
+
if (!res.ok) setError('Failed to save');
|
|
52
|
+
|
|
53
|
+
// ✅ RIGHT
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
const body = await res.json().catch(() => ({}));
|
|
56
|
+
setError(body.error ?? `Save failed (HTTP ${res.status})`);
|
|
57
|
+
}
|
|
58
|
+
```
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: assembly-native-resources
|
|
3
|
+
description: Use any time the app references a native Assembly message channel, form, or portal file/file channel. One non-negotiable rule — never hardcode a native resource ID; discover it at runtime. These IDs are workspace-scoped, so a hardcoded one is only valid in the workspace it came from and breaks the moment the app runs anywhere else. (Not react-hook-form validation — that's assembly-forms. Not app-private uploads — that's assembly-storage. This IS the skill for the native Messages, Forms and Files modules.)
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## The non-negotiable
|
|
7
|
+
|
|
8
|
+
1. **Never hardcode a native resource ID.** A message channel id, form id, file id or file channel id is minted by one workspace and is **only valid in the workspace it came from**. Paste one into source and the app works exactly once — in the workspace you built it in. Anywhere else the lookup returns nothing and the feature silently does nothing, or the call fails. There is no data fix for this: the broken value is in the code.
|
|
9
|
+
|
|
10
|
+
Discover instead. The pattern is always the same two steps: **list, then match on a stable attribute** the workspace owner controls (a title, a name), or resolve the resource from the current request's identity. Never a literal.
|
|
11
|
+
|
|
12
|
+
## Discovering a form
|
|
13
|
+
|
|
14
|
+
A form's display name is on `fields`, not on the form itself:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
const { data: forms = [] } = await assembly.listForms({});
|
|
18
|
+
const intakeForm = forms.find(
|
|
19
|
+
(f) => f.fields?.name?.toLowerCase().includes('intake'), // match the user's wording
|
|
20
|
+
);
|
|
21
|
+
if (!intakeForm?.id) {
|
|
22
|
+
return <div>The intake form hasn't been set up in this workspace yet.</div>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Assign it to one or more clients. Recipients go in clientIds — a bare
|
|
26
|
+
// clientId is not a field on this request and the call 400s without one.
|
|
27
|
+
await assembly.requestFormResponse({
|
|
28
|
+
requestBody: { formId: intakeForm.id, clientIds: [clientId] },
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
There is **no** method for a custom app to submit answers to a native form — you can request a response from a client and read `listFormResponses`, nothing more. Build your own form (see `assembly-forms`) if the app needs to collect the answers itself.
|
|
33
|
+
|
|
34
|
+
## Discovering a message channel
|
|
35
|
+
|
|
36
|
+
A channel has no name — it is identified by who is in it, so resolve it from the viewer rather than searching by title. `getSessionIdentity()` returns `null` when there is no session, and `clientId` is absent for a staff viewer, so branch before you filter:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
const identity = await getSessionIdentity();
|
|
40
|
+
// { workspaceId, clientId?, companyId?, internalUserId? } | null
|
|
41
|
+
if (!identity?.clientId) {
|
|
42
|
+
return <div>Open this from a client's portal to see their messages.</div>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// membershipType narrows to the client's own 1:1 channel; without it a client
|
|
46
|
+
// who belongs to a company also matches that company's channel.
|
|
47
|
+
const { data: channels = [] } = await assembly.listMessageChannels({
|
|
48
|
+
clientId: identity.clientId,
|
|
49
|
+
membershipType: 'individual',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const channel = channels?.[0];
|
|
53
|
+
if (!channel?.id) {
|
|
54
|
+
return <div>This client doesn't have a message channel yet.</div>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await assembly.sendMessage({
|
|
58
|
+
requestBody: { channelId: channel.id, text: 'Your report is ready.' },
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`retrieveMessageChannel({ id })` is for a channel id you already resolved this request — never for one typed into the source.
|
|
63
|
+
|
|
64
|
+
## Discovering a file channel
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
const identity = await getSessionIdentity();
|
|
68
|
+
if (!identity?.clientId) {
|
|
69
|
+
return <div>Open this from a client's portal to see their files.</div>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const { data: fileChannels = [] } = await assembly.listFileChannels({
|
|
73
|
+
clientId: identity.clientId,
|
|
74
|
+
membershipType: 'individual',
|
|
75
|
+
});
|
|
76
|
+
const channel = fileChannels?.[0];
|
|
77
|
+
if (!channel?.id) {
|
|
78
|
+
return <div>This client has no portal file channel yet.</div>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { data: files = [] } = await assembly.listFiles({ channelId: channel.id });
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
For files the app itself owns (attachments, exports), don't use the Files module at all — use the storage helpers in `@assembly-js/app-foundation/server` (see `assembly-storage`). Files is only for things the user wants to see in their portal Files tab.
|
|
85
|
+
|
|
86
|
+
## Storing an ID (this is allowed)
|
|
87
|
+
|
|
88
|
+
Persisting a native id in the app's **own database** is fine:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
await db.insert(reports).values({ clientId, fileChannelId: channel.id });
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Those rows are workspace-local, and a copy of the app into another workspace starts with an empty database — so nothing stale comes along. The ban is on the **literal in source**, which does travel.
|
|
95
|
+
|
|
96
|
+
## When the resource isn't there
|
|
97
|
+
|
|
98
|
+
Render an explicit, honest empty state and stop — "The intake form hasn't been set up in this workspace yet." Do **not** fall back to a hardcoded id, and do not silently render an empty table as though the feature were working. A missing native resource is a real workspace-configuration state, not an error to paper over.
|
|
99
|
+
|
|
100
|
+
## Symptoms when it goes wrong
|
|
101
|
+
|
|
102
|
+
| Mistake | What you'll see |
|
|
103
|
+
|---|---|
|
|
104
|
+
| Hardcoded a form / channel / file id | Works in the workspace you built it in; everywhere else the lookup returns nothing and the feature does nothing |
|
|
105
|
+
| Matched on the id instead of a stable attribute | Same — an id is not stable across workspaces, a name is |
|
|
106
|
+
| Matched a form on `form.name` | Always undefined; the display name is `form.fields.name`, so nothing ever matches and the app renders its empty state forever |
|
|
107
|
+
| Fell back to a literal id when discovery found nothing | The feature appears to work and writes into the wrong place, or fails on a resource the workspace can't see |
|
|
108
|
+
| Used the Files module for app-private uploads | Attachments show up in the client's portal Files tab |
|
|
109
|
+
|
|
110
|
+
## Edge cases
|
|
111
|
+
|
|
112
|
+
- **Nested SDK calls don't exist.** The SDK is flat: `assembly.listForms({})`, never `assembly.forms.list()`.
|
|
113
|
+
- **Every list method takes an options object**, even when you pass no filters — `listForms({})`, not `listForms()`.
|
|
114
|
+
- **List responses are paginated envelopes**, and `data` is nullable: destructure with a default and still index defensively (`const { data: forms = [] } = …; forms?.[0]`).
|
|
115
|
+
- **Narrow before you index.** Taking `[0]` off an unfiltered list is the same guess as a hardcoded id. Filter by `membershipType` (and `companyId` for a client in multiple companies); if two candidates genuinely remain, surface the choice rather than picking one.
|
|
116
|
+
- **Creating a channel is not a substitute for finding one.** `createMessageChannel` requires a `membershipType`, and its type is a string enum the SDK does not re-export from its package root — you cannot satisfy it with a plain `'individual'` string. Work with the channels the workspace already has; check the SDK types first if you genuinely need to create one.
|
|
117
|
+
- **Ids from the app bridge or a route param** are runtime values, not literals — those are fine to pass straight to `retrieve*`, after the usual auth and scope checks (see `assembly-route-auth`).
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: assembly-route-auth
|
|
3
|
+
description: Use for every app/api/.../[id]/route.ts handler — GET and write alike. Every handler must call requireAuth then requireCompanyScope from @assembly-js/app-foundation/server before returning or mutating data, at parity with the page-level ownership guard.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Every `app/api/.../[id]/route.ts` handler — **GET included** — must call `requireAuth` then `requireCompanyScope` (from `@assembly-js/app-foundation/server`) before returning or mutating data. Skipping either guard on a GET handler is a cross-tenant IDOR: an authenticated user in company A can read company B's records by changing the id in the URL.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { requireAuth, requireCompanyScope, handleRouteError } from '@assembly-js/app-foundation/server';
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## How the helpers work
|
|
13
|
+
|
|
14
|
+
**`requireAuth(req)`** decodes the viewer's identity from the bearer (read from the request cookie — no `x-assembly-token` header) and returns it. Returns a 401 `NextResponse` (with a `{ code: 'token_expired' }` body and an `x-assembly-token-expired: 1` header) when the bearer is missing or expired — `authFetch` detects this and re-exchanges + retries automatically.
|
|
15
|
+
|
|
16
|
+
**`requireCompanyScope(identity, resource, assembly?)`** asserts the viewer can access a resource scoped to a company **or to a client**:
|
|
17
|
+
|
|
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
|
+
- 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
|
+
- 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.
|
|
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
|
+
|
|
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
|
+
|
|
26
|
+
### `requireCompanyScope` does not check client ownership on a company-scoped row
|
|
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.
|
|
48
|
+
|
|
49
|
+
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
|
+
|
|
51
|
+
## GET example
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
// app/api/items/[id]/route.ts
|
|
55
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
56
|
+
import {
|
|
57
|
+
requireAuth,
|
|
58
|
+
requireCompanyScope,
|
|
59
|
+
getAssemblyForRequest,
|
|
60
|
+
handleRouteError,
|
|
61
|
+
parseUuid,
|
|
62
|
+
} from '@assembly-js/app-foundation/server';
|
|
63
|
+
import { db } from '@/lib/db';
|
|
64
|
+
import { items } from '@/lib/schema';
|
|
65
|
+
import { eq } from 'drizzle-orm';
|
|
66
|
+
|
|
67
|
+
export async function GET(
|
|
68
|
+
request: NextRequest,
|
|
69
|
+
{ params }: { params: Promise<{ id: string }> },
|
|
70
|
+
) {
|
|
71
|
+
const auth = await requireAuth(request);
|
|
72
|
+
if (auth instanceof NextResponse) return auth;
|
|
73
|
+
|
|
74
|
+
const { id: rawId } = await params;
|
|
75
|
+
// items.id is uuid — validate at the lookup, not because the segment is named [id]
|
|
76
|
+
const id = parseUuid(rawId);
|
|
77
|
+
if (id instanceof NextResponse) return id;
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const item = await db.query.items.findFirst({ where: eq(items.id, id) });
|
|
81
|
+
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
82
|
+
|
|
83
|
+
// Only needed for the multi-company membership check; omit otherwise.
|
|
84
|
+
const assembly = await getAssemblyForRequest(request);
|
|
85
|
+
if (assembly instanceof NextResponse) return assembly;
|
|
86
|
+
// Pass both scope columns — clientId is what lets a client-scoped row
|
|
87
|
+
// (companyId null) pass for its owning client.
|
|
88
|
+
const scopeError = await requireCompanyScope(
|
|
89
|
+
auth,
|
|
90
|
+
{ companyId: item.companyId, clientId: item.clientId },
|
|
91
|
+
assembly,
|
|
92
|
+
);
|
|
93
|
+
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
|
+
|
|
100
|
+
return NextResponse.json(item);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return handleRouteError(err);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Write (POST/PATCH) example
|
|
108
|
+
|
|
109
|
+
Apply the same guard before any mutation. Mutating without it lets an authenticated user from company A modify company B's data.
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
// app/api/items/[id]/route.ts — PATCH handler in the same file
|
|
113
|
+
export async function PATCH(
|
|
114
|
+
request: NextRequest,
|
|
115
|
+
{ params }: { params: Promise<{ id: string }> },
|
|
116
|
+
) {
|
|
117
|
+
const auth = await requireAuth(request);
|
|
118
|
+
if (auth instanceof NextResponse) return auth;
|
|
119
|
+
|
|
120
|
+
const { id: rawId } = await params;
|
|
121
|
+
const id = parseUuid(rawId);
|
|
122
|
+
if (id instanceof NextResponse) return id;
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const item = await db.query.items.findFirst({ where: eq(items.id, id) });
|
|
126
|
+
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
127
|
+
|
|
128
|
+
const assembly = await getAssemblyForRequest(request);
|
|
129
|
+
if (assembly instanceof NextResponse) return assembly;
|
|
130
|
+
const scopeError = await requireCompanyScope(
|
|
131
|
+
auth,
|
|
132
|
+
{ companyId: item.companyId, clientId: item.clientId },
|
|
133
|
+
assembly,
|
|
134
|
+
);
|
|
135
|
+
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
|
+
|
|
140
|
+
const body = await request.json();
|
|
141
|
+
// Never spread the full body — callers could include companyId or id
|
|
142
|
+
// and reparent / re-identify the resource. Extract only mutable fields.
|
|
143
|
+
const { name, description } = body; // replace with the actual mutable fields
|
|
144
|
+
await db.update(items).set({ name, description }).where(eq(items.id, id));
|
|
145
|
+
return NextResponse.json({ ok: true });
|
|
146
|
+
} catch (err) {
|
|
147
|
+
return handleRouteError(err, 'Update failed');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Sub-resource routes
|
|
153
|
+
|
|
154
|
+
The same guard applies to **every level of nesting**. If a project has highlights, the highlight handler must scope to the highlight's own owning company or client — not just the parent project.
|
|
155
|
+
|
|
156
|
+
Load the **sub-resource** from the database first, then call `requireCompanyScope` with the sub-resource's own `companyId` and `clientId`. Do NOT skip the scope check on the assumption that the parent route already guards access.
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// app/api/projects/[id]/highlights/[highlightId]/route.ts
|
|
160
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
161
|
+
import {
|
|
162
|
+
requireAuth,
|
|
163
|
+
requireCompanyScope,
|
|
164
|
+
getAssemblyForRequest,
|
|
165
|
+
handleRouteError,
|
|
166
|
+
parseUuid,
|
|
167
|
+
} from '@assembly-js/app-foundation/server';
|
|
168
|
+
import { db } from '@/lib/db';
|
|
169
|
+
import { highlights } from '@/lib/schema';
|
|
170
|
+
import { eq } from 'drizzle-orm';
|
|
171
|
+
|
|
172
|
+
export async function GET(
|
|
173
|
+
request: NextRequest,
|
|
174
|
+
{ params }: { params: Promise<{ id: string; highlightId: string }> },
|
|
175
|
+
) {
|
|
176
|
+
const auth = await requireAuth(request);
|
|
177
|
+
if (auth instanceof NextResponse) return auth;
|
|
178
|
+
|
|
179
|
+
const { highlightId: rawHighlightId } = await params;
|
|
180
|
+
// highlights.id is uuid — validate at the lookup
|
|
181
|
+
const highlightId = parseUuid(rawHighlightId, 'highlight id');
|
|
182
|
+
if (highlightId instanceof NextResponse) return highlightId;
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const highlight = await db.query.highlights.findFirst({
|
|
186
|
+
where: eq(highlights.id, highlightId),
|
|
187
|
+
});
|
|
188
|
+
if (!highlight) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
189
|
+
|
|
190
|
+
const assembly = await getAssemblyForRequest(request);
|
|
191
|
+
if (assembly instanceof NextResponse) return assembly;
|
|
192
|
+
const scopeError = await requireCompanyScope(
|
|
193
|
+
auth,
|
|
194
|
+
{ companyId: highlight.companyId, clientId: highlight.clientId },
|
|
195
|
+
assembly,
|
|
196
|
+
);
|
|
197
|
+
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
|
+
|
|
202
|
+
return NextResponse.json(highlight);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
return handleRouteError(err);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The same pattern applies to milestones, comments, assets, or any other sub-resource nested under a parent `[id]` route.
|
|
210
|
+
|
|
211
|
+
## The rule
|
|
212
|
+
|
|
213
|
+
Every `[id]`-nested route handler — regardless of HTTP method or nesting depth — must follow this sequence:
|
|
214
|
+
|
|
215
|
+
1. `const auth = await requireAuth(request); if (auth instanceof NextResponse) return auth;`
|
|
216
|
+
2. Load the resource (or sub-resource) from the database inside `try/catch`. If the lookup uses a uuid column, validate with `parseUuid` first (see `assembly-route-errors`). Use `handleRouteError` in the catch — never return `err.message`.
|
|
217
|
+
3. Return 404 if not found.
|
|
218
|
+
4. `const assembly = await getAssemblyForRequest(request); if (assembly instanceof NextResponse) return assembly;`
|
|
219
|
+
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.
|
|
222
|
+
|
|
223
|
+
Never skip steps 1, 5, or 6, even on GET handlers.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: assembly-route-errors
|
|
3
|
+
description: Use in every app/api/**/route.ts handler. Return user-friendly { error } JSON with the right status; never return err.message from catch blocks — Postgres and driver strings leak to the UI. Import handleRouteError, badRequest, notFound, and internalError from @assembly-js/app-foundation/server. Validate values before they hit typed DB columns (e.g. parseUuid before a uuid-column lookup).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Route handlers must return safe, user-facing error messages. A `catch` that does `NextResponse.json({ error: err.message }, { status: 500 })` leaks raw Postgres errors (`invalid input syntax for type uuid: "…"`) and other driver internals to the client.
|
|
7
|
+
|
|
8
|
+
Pre-built helpers in `@assembly-js/app-foundation/server`:
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { badRequest, handleRouteError, internalError, notFound } from '@assembly-js/app-foundation/server';
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
- **`badRequest(message)`** → 400
|
|
15
|
+
- **`notFound(message?)`** → 404 (default message: `Not found`)
|
|
16
|
+
- **`internalError(message?)`** → 500 (default: `Something went wrong`)
|
|
17
|
+
- **`handleRouteError(err, fallback?)`** → maps known DB input errors to 400, everything else to a safe 500. Logs the real error with `console.error`; never forwards `err.message`.
|
|
18
|
+
|
|
19
|
+
## The rule
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
export async function POST(request: NextRequest) {
|
|
23
|
+
try {
|
|
24
|
+
// ... handler logic ...
|
|
25
|
+
return NextResponse.json({ ok: true });
|
|
26
|
+
} catch (err) {
|
|
27
|
+
return handleRouteError(err, 'Save failed');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Wrong** — leaks driver/Postgres strings:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
} catch (err) {
|
|
36
|
+
const message = err instanceof Error ? err.message : 'Failed';
|
|
37
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Right** — safe client message, real error logged server-side:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return handleRouteError(err, 'Save failed');
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
When you know the failure mode without a catch (validation, missing resource), return the specific helper directly — don't throw just to catch:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
if (!name?.trim()) return badRequest('Name is required');
|
|
53
|
+
if (!item) return notFound();
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Validate before typed database lookups
|
|
57
|
+
|
|
58
|
+
Validate a value **when you are about to pass it into a typed Drizzle column** — not because it arrived as a route/query param. Route segments can be slugs, external ids, or other non-uuid strings; those are fine. The failure mode is querying a `uuid` column with a non-uuid value (Postgres throws; a naive catch leaks the string).
|
|
59
|
+
|
|
60
|
+
`handleRouteError` maps that Postgres error to a safe 400 as a backstop. Prefer validating at the lookup site so you return a clearer message and server components can call `notFound()` instead of throwing.
|
|
61
|
+
|
|
62
|
+
For uuid columns (the default PK in `lib/schema.ts`), use `isUuid` / `parseUuid` **at the query**:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { isUuid, parseUuid } from '@assembly-js/app-foundation/server';
|
|
66
|
+
import { notFound } from 'next/navigation'; // server components/pages
|
|
67
|
+
|
|
68
|
+
// Right before eq(items.id, …) when items.id is uuid:
|
|
69
|
+
const id = parseUuid(rawId);
|
|
70
|
+
if (id instanceof NextResponse) return id;
|
|
71
|
+
const item = await db.query.items.findFirst({ where: eq(items.id, id) });
|
|
72
|
+
|
|
73
|
+
// Server component / page loading a uuid PK:
|
|
74
|
+
if (!isUuid(id)) notFound();
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Do **not** blanket-`parseUuid` every route param. Only guard values that will hit a uuid column. For other column types, validate the shape/range yourself before the query (required fields, enums, numeric bounds) — same idea: reject early with `badRequest` / `notFound`, don't let the database be the first validator.
|
|
78
|
+
|
|
79
|
+
## Mutation UIs still read `{ error }`
|
|
80
|
+
|
|
81
|
+
Clients should keep surfacing `body.error` from failed responses — `handleRouteError` and the other helpers use the same `{ error: string }` shape. The fix is what the server puts in that string, not hiding errors from the UI.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: assembly-semantic-colors
|
|
3
|
+
description: Use when displaying status badges, alerts, or any UI that communicates state to users. Covers the semantic color variants for Badge and Alert components.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Semantic color variants
|
|
7
|
+
|
|
8
|
+
Assembly UI provides four semantic color variants for Badge and Alert: `destructive`, `success`, `warning`, and `info`. Each has a purpose-built background and foreground color pair.
|
|
9
|
+
|
|
10
|
+
### When to use each variant
|
|
11
|
+
|
|
12
|
+
| Variant | Meaning | When to use | Example labels |
|
|
13
|
+
|---|---|---|---|
|
|
14
|
+
| `info` | Important information that needs attention | Default choice for status badges — use instead of `default` variant for most status indicators | Running, Open, In transit, Pending, Active |
|
|
15
|
+
| `success` | Positive outcome, no action required | Completed actions, successful states | Success, Paid, Signed, Complete, Verified |
|
|
16
|
+
| `warning` | Something needs attention or action | Problems that require user intervention | Overdue, Inactive, Expiring, At risk |
|
|
17
|
+
| `destructive` | Error or failure state | Errors, failed operations, critical issues | Failed, Rejected, Cancelled, Error |
|
|
18
|
+
|
|
19
|
+
### Badge
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
export PATH="$HOME/.bun/bin:$PATH" && bunx shadcn@latest add @assembly-ui/badge --yes --overwrite
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { Badge } from "@/components/ui/badge";
|
|
27
|
+
|
|
28
|
+
<Badge variant="info">Open</Badge>
|
|
29
|
+
<Badge variant="success">Paid</Badge>
|
|
30
|
+
<Badge variant="warning">Overdue</Badge>
|
|
31
|
+
<Badge variant="destructive">Failed</Badge>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Prefer `variant="info"` over `variant="default"` for status badges. The `default` variant (dark background) is for non-semantic labels like categories or tags. Use `secondary` for neutral metadata.
|
|
35
|
+
|
|
36
|
+
### Alert
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
export PATH="$HOME/.bun/bin:$PATH" && bunx shadcn@latest add @assembly-ui/alert --yes --overwrite
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```tsx
|
|
43
|
+
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
|
44
|
+
import { CircleCheck, TriangleAlert, Info, CircleX } from "lucide-react";
|
|
45
|
+
|
|
46
|
+
<Alert variant="info">
|
|
47
|
+
<Info />
|
|
48
|
+
<AlertTitle>New feature available</AlertTitle>
|
|
49
|
+
<AlertDescription>Dark mode support has been added to your account settings.</AlertDescription>
|
|
50
|
+
</Alert>
|
|
51
|
+
|
|
52
|
+
<Alert variant="success">
|
|
53
|
+
<CircleCheck />
|
|
54
|
+
<AlertTitle>Payment processed</AlertTitle>
|
|
55
|
+
<AlertDescription>Your payment of $29.99 has been confirmed.</AlertDescription>
|
|
56
|
+
</Alert>
|
|
57
|
+
|
|
58
|
+
<Alert variant="warning">
|
|
59
|
+
<TriangleAlert />
|
|
60
|
+
<AlertTitle>Storage almost full</AlertTitle>
|
|
61
|
+
<AlertDescription>You have used 90% of your storage. Consider upgrading.</AlertDescription>
|
|
62
|
+
</Alert>
|
|
63
|
+
|
|
64
|
+
<Alert variant="destructive">
|
|
65
|
+
<CircleX />
|
|
66
|
+
<AlertTitle>Payment failed</AlertTitle>
|
|
67
|
+
<AlertDescription>Please check your payment method and try again.</AlertDescription>
|
|
68
|
+
</Alert>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Match the icon to the variant: `Info` for info, `CircleCheck` for success, `TriangleAlert` for warning, `CircleX` for destructive.
|
|
72
|
+
|
|
73
|
+
### Key rules
|
|
74
|
+
|
|
75
|
+
- Status indicators should almost always use a semantic variant — avoid plain `default` badges for statuses
|
|
76
|
+
- Don't use `warning` for informational states or `info` for error states — respect the semantic meaning
|