@assembly-js/app-foundation 0.0.27 → 0.0.29

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
@@ -20,7 +20,7 @@ When a request implies a scope or permission change, apply it and report the pro
20
20
 
21
21
  Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them by name as you reach for the relevant concept — don't re-read this file's general sections when a skill exists. Available skills:
22
22
 
23
- - `assembly-internal-users` — staff in the Dashboard; Client Access permissions (`isClientAccessLimited`, `companyAccessList`) and scoping internal views to allowed companies
23
+ - `assembly-internal-users` — staff in the Dashboard; workspace role (`admin` or `staff`) for admin-only features; Client Access permissions (`isClientAccessLimited`, `companyAccessList`) and scoping internal views to allowed companies
24
24
  - `assembly-client-users` — customers in the Client Portal
25
25
  - `assembly-companies` — many-to-many with clients; placeholder companies (excluded from `listCompanies` by default for these apps)
26
26
  - `assembly-clients-companies` — REQUIRED when the app stores or scopes data by client or company: scoped tables carry BOTH a `clientId` and a `companyId` column and are filtered on the intersection (never one dimension); a client viewer always has an active `companyId`; `companyIds[]` builds pickers only and `client.companyId` is deprecated
@@ -52,6 +52,7 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
52
52
  - `assembly-semantic-colors` — semantic color variants (info, success, warning, destructive) for Badge and Alert
53
53
  - `assembly-workspace-labels` — workspace-customized display labels for clients/companies. The server helper comes from `@assembly-js/app-foundation/server`, the `useLabels()` hook from the repo — import them, don't rewrite them.
54
54
  - `assembly-db-transactions` — REQUIRED when a route makes more than one DB write: `db.transaction()` is unsupported on neon-http and 500s. Use `db.batch([...])` for atomic multi-writes, sequential awaits otherwise.
55
+ - `assembly-record-writes` — REQUIRED when a form creates a record that a later save also writes, especially with nested rows (packages, add-ons, line items, blocks): create and save call one writer so create cannot drop submitted fields.
55
56
  - `assembly-session-token` — the bearer lifecycle and how to read identity or fetch data: `getSessionIdentity()`/`useAssembly()`, `useServerAction`/`callAction`, `authFetch`. You never handle a token yourself.
56
57
  - `assembly-route-auth` — REQUIRED on every `app/api/.../[id]/route.ts` handler: `requireAuth` + `requireCompanyScope` before returning or mutating data.
57
58
  - `assembly-scheduled-work` — REQUIRED when the prompt implies anything on a schedule or while nobody has the app open (remind, daily digest, every week, follow up in N days, at a specific time): one `*/30 * * * *` Vercel cron in `vercel.json` hitting one dispatcher route, every scheduled action a row in a schedule table, `CRON_SECRET` declared in `requiredSecrets` and verified on every tick, `getOfflineAssembly()` for platform calls. Checking for due work on page load ships a schedule that never fires.
@@ -349,6 +350,7 @@ export const items = pgTable('items', {
349
350
  - Use `drizzle-kit push` (not `migrate`) — no migration files; schema is pushed directly.
350
351
  - Use `@neondatabase/serverless` with `drizzle-orm/neon-http` — not `pg` or `postgres.js`. It's the standard driver for the Vercel serverless (Node) runtime this app deploys to; `lib/db.ts` already wires it up.
351
352
  - **No interactive transactions.** `db.transaction(async (tx) => …)` is unsupported on neon-http and throws (a 500). For atomic multi-writes use `db.batch([...])`; otherwise sequential `await`s. See the `assembly-db-transactions` skill.
353
+ - **Create writes every field the create form submits.** If a later save writes nested rows (packages, add-ons, blocks), create calls that same writer with the submitted payload. A success toast and a redirect to the editor do not save. See `assembly-record-writes`.
352
354
  - `DATABASE_URL` is already in the shell env. Do NOT hardcode it or write it to `.env.local`.
353
355
  - Use `db` from `lib/db.ts` in **server components, server actions, and route handlers only** — never in client components. This is enforced: `lib/db.ts` imports `server-only`, so pulling it (or anything that imports it, e.g. a lib file re-exporting constants) into a `'use client'` module fails `bun run build`. Keep client-shared constants/types in their own file, not next to server queries.
354
356
  - **Validate values before they hit typed columns — not because they arrived as route params.** Route segments can be slugs or external ids. When the next step is a uuid-column lookup, use `parseUuid` / `isUuid` from `@assembly-js/app-foundation/server` so a bad value 400/404s instead of Postgres throwing `invalid input syntax for type uuid`. In server components/pages: `if (!isUuid(id)) notFound()` before the query.
@@ -3,6 +3,11 @@ export interface ClientAccessScope {
3
3
  isLimited: boolean;
4
4
  allowedCompanyIds: Set<string>;
5
5
  }
6
+ export interface InternalUserAccess {
7
+ isWorkspaceAdmin: boolean;
8
+ scope: ClientAccessScope;
9
+ }
10
+ export declare function getInternalUserAccess(internalUserId: string, tokenOrAssembly?: string | AssemblyAPI): Promise<InternalUserAccess>;
6
11
  export declare function getClientAccessScope(internalUserId: string, tokenOrAssembly?: string | AssemblyAPI): Promise<ClientAccessScope>;
7
12
  export declare function canAccessCompanies(scope: ClientAccessScope, companyIds: string[] | null | undefined): boolean;
8
13
  export declare function canAccessClient<T extends {
@@ -9,17 +9,16 @@ const DENY_ALL = {
9
9
  isLimited: true,
10
10
  allowedCompanyIds: new Set(),
11
11
  };
12
- // Resolves the Client Access scope for an internal user.
13
- //
14
- // Fails closed: if the lookup fails we return DENY_ALL rather than treating the
15
- // viewer as unrestricted. An unverified viewer must not be handed the access of
16
- // a full-access one — the caller sees empty lists (or a 404 via
12
+ const NO_ACCESS = { isWorkspaceAdmin: false, scope: DENY_ALL };
13
+ // Fails closed: if the lookup fails we return NO_ACCESS rather than treating the
14
+ // viewer as an unrestricted admin. An unverified viewer must not be handed the
15
+ // access of a full-access one — the caller sees empty lists (or a 404 via
17
16
  // canAccessCompanies) instead of another workspace member's data. Callers that
18
17
  // prefer to surface a transient failure should catch it upstream and retry.
19
18
  //
20
- // The session token carries only `internalUserId`, never the access fields, so
21
- // this costs one `retrieveInternalUser` per request that needs it.
22
- export async function getClientAccessScope(internalUserId, tokenOrAssembly) {
19
+ // The session token carries only `internalUserId`, never the role or access
20
+ // fields, so this costs one `retrieveInternalUser` per request that needs it.
21
+ export async function getInternalUserAccess(internalUserId, tokenOrAssembly) {
23
22
  try {
24
23
  // Same narrowing as requireCompanyScope: a session token string on the
25
24
  // legacy path, an already-authenticated instance on the bearer path.
@@ -27,17 +26,19 @@ export async function getClientAccessScope(internalUserId, tokenOrAssembly) {
27
26
  ? await getAssembly(tokenOrAssembly)
28
27
  : tokenOrAssembly;
29
28
  const user = await assembly.retrieveInternalUser({ id: internalUserId });
30
- if (user.isClientAccessLimited !== true)
31
- return UNRESTRICTED;
32
- return {
33
- isLimited: true,
34
- allowedCompanyIds: new Set(user.companyAccessList ?? []),
35
- };
29
+ const scope = user.isClientAccessLimited === true
30
+ ? { isLimited: true, allowedCompanyIds: new Set(user.companyAccessList ?? []) }
31
+ : UNRESTRICTED;
32
+ return { isWorkspaceAdmin: user.role === 'admin', scope };
36
33
  }
37
34
  catch {
38
- return DENY_ALL;
35
+ return NO_ACCESS;
39
36
  }
40
37
  }
38
+ // Resolves only the Client Access scope, for callers that never gate on role.
39
+ export async function getClientAccessScope(internalUserId, tokenOrAssembly) {
40
+ return (await getInternalUserAccess(internalUserId, tokenOrAssembly)).scope;
41
+ }
41
42
  // True when the viewer may see a resource belonging to any of `companyIds`.
42
43
  //
43
44
  // Mirrors the platform's own users.User.IsAccessAllowed: access is company-scoped,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "devDependencies": {
45
45
  "@assembly-js/app-bridge": "1.3.0",
46
- "@assembly-js/node-sdk": "4.2.3-beta.2",
46
+ "@assembly-js/node-sdk": "4.2.4",
47
47
  "@types/bun": "^1.1.0",
48
48
  "@types/node": "^22",
49
49
  "next": "^15.5.0",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: assembly-internal-users
3
- description: Use when the app reads/lists internal users, or an internal viewer browses clients/companies in the Dashboard. Covers Client Access (isClientAccessLimited, companyAccessList) scoping. Internal users sign into the Dashboard only. Detect via tokenPayload.internalUserId.
3
+ description: Use when the app reads/lists internal users, gates a feature to workspace admins (role is "admin" or "staff"), or an internal viewer browses clients/companies in the Dashboard. Covers Client Access (isClientAccessLimited, companyAccessList) scoping. Internal users sign into the Dashboard only. Detect via tokenPayload.internalUserId.
4
4
  ---
5
5
 
6
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).
@@ -23,6 +23,24 @@ If `internalUserId` is set, the current viewer is staff. (`clientId` is set for
23
23
  - `listInternalUsers({ nextToken, limit })` — paged list of all staff in the workspace
24
24
  - `retrieveInternalUser({ id })` — single record by id
25
25
 
26
+ ## Workspace role — admins vs staff
27
+
28
+ Every internal user has a workspace role: `"admin"` or `"staff"`. The workspace owner is an admin. When the app has admin-only features ("admins manage templates", "only admins can change settings"), gate them on this role. Do not invent an app-level role table or admin list for this, and never leave the check stubbed off until a role source is "confirmed": this is the role source.
29
+
30
+ ```ts
31
+ import { getInternalUserAccess, getSessionIdentity } from '@assembly-js/app-foundation/server';
32
+
33
+ const identity = await getSessionIdentity();
34
+ if (!identity?.internalUserId) return { ok: false, message: 'Only your team can manage templates.' };
35
+
36
+ const access = await getInternalUserAccess(identity.internalUserId);
37
+ if (!access.isWorkspaceAdmin) {
38
+ return { ok: false, message: 'Only workspace admins can manage templates.' };
39
+ }
40
+ ```
41
+
42
+ Apply the same check on the server action or route, not only in the UI. `access.scope` is the viewer's Client Access scope from the same lookup (below). If the app needs its own job titles beyond admin and staff (approver, manager), store those in the app; workspace admins must still be able to manage them.
43
+
26
44
  ## Client Access permissions
27
45
 
28
46
  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.
@@ -36,7 +54,7 @@ Access is **company-scoped**: a limited user can see a client iff **any** of the
36
54
 
37
55
  ## Pre-built helpers
38
56
 
39
- `@assembly-js/app-foundation/server` exports these — import them, do not rewrite the check.
57
+ `@assembly-js/app-foundation/server` exports these — import them, do not rewrite the check. When the request also needs the admin check, call `getInternalUserAccess` once and use its `scope` instead of a second lookup.
40
58
 
41
59
  ```ts
42
60
  import {
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: assembly-record-writes
3
+ description: REQUIRED when a form creates a persisted record that a later save or update also writes, especially when the payload includes nested rows (packages, add-ons, line items, blocks, children). Create and save must call one writer so create cannot drop fields the form submitted. A success toast after create does not mean those rows were stored.
4
+ ---
5
+
6
+ A create form often submits the same payload a later edit form saves: parent columns plus nested collections. If create inserts only the parent (and maybe one child table and an audit event) while save writes the rest, the first submit looks successful and the editor opens empty. The redirect after create does not call save.
7
+
8
+ ## The rule
9
+
10
+ One function writes the submitted payload. Create calls it for a new id; save calls it for an existing id.
11
+
12
+ ```ts
13
+ async function writeProposal(id: string, input: ProposalInput) {
14
+ await db.insert(proposals).values({ id, title: input.title, intro: input.intro })
15
+ .onConflictDoUpdate({ target: proposals.id, set: { title: input.title, intro: input.intro } });
16
+ await replaceRows(packages, id, input.packages);
17
+ await replaceRows(addOns, id, input.addOns);
18
+ await replaceRows(blocks, id, input.blocks);
19
+ }
20
+
21
+ export async function createProposal(input: ProposalInput) {
22
+ const id = crypto.randomUUID();
23
+ await writeProposal(id, input);
24
+ await recordEvent(id, 'draft_created');
25
+ return id;
26
+ }
27
+
28
+ export async function saveProposal(id: string, input: ProposalInput) {
29
+ await writeProposal(id, input);
30
+ }
31
+ ```
32
+
33
+ Leave these off the shared payload. They belong to the path that owns them:
34
+
35
+ - ids, timestamps, a draft status, an audit-event row
36
+ - a control flag that chooses an action (for example "send immediately") and is not itself stored, unless save stores that same flag
37
+ - fields that appear only on a later edit form
38
+
39
+ If the form submits it, the create call chain writes it, including by calling the shared writer.