@assembly-js/app-foundation 0.0.2-rc.3 → 0.0.2

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
@@ -39,7 +39,7 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
39
39
  - `assembly-ai` — AI features via Assembly's OpenAI proxy (no API keys needed)
40
40
  - `assembly-storage` — app-private file uploads/downloads (NOT the portal Files module). The storage helpers come from `@assembly-js/app-foundation/server` — import them, don't rewrite them. POST only `{ fileName }` (a bare name) and store the key the platform returns; never build the S3 key yourself.
41
41
  - `assembly-integrations` (present only when third-party integrations are enabled for this workspace) — third-party providers (Slack, etc.) via `requiredIntegrations` + `getIntegration()`. If this skill is present, read it before declaring an integration or writing any provider call — it lists what's connectable and each provider's gotchas. If it is absent, managed OAuth integrations are not available here; use `requiredSecrets` for any external service
42
- - `assembly-avatar-sub` — row-duplication + AvatarSub pattern for showing which company a client row represents (multi-company clients)
42
+ - `assembly-avatar-sub` — AvatarSub company badge wherever a client is rendered, including tables of your own records that reference a client; row duplication only for lists OF clients
43
43
  - `assembly-user-company-selector` — UserCompanySelector for picking clients, internal users, companies
44
44
  - `assembly-forms` — react-hook-form + zod + Field component validation pattern
45
45
  - `assembly-semantic-colors` — semantic color variants (info, success, warning, destructive) for Badge and Alert
@@ -0,0 +1,10 @@
1
+ import type { AssemblyAPI } from '@assembly-js/node-sdk';
2
+ export interface ClientAccessScope {
3
+ isLimited: boolean;
4
+ allowedCompanyIds: Set<string>;
5
+ }
6
+ export declare function getClientAccessScope(internalUserId: string, tokenOrAssembly?: string | AssemblyAPI): Promise<ClientAccessScope>;
7
+ export declare function canAccessCompanies(scope: ClientAccessScope, companyIds: string[] | null | undefined): boolean;
8
+ export declare function canAccessClient<T extends {
9
+ companyIds?: string[];
10
+ }>(scope: ClientAccessScope, client: T): boolean;
@@ -0,0 +1,62 @@
1
+ import { getAssembly } from './assembly.js';
2
+ const UNRESTRICTED = {
3
+ isLimited: false,
4
+ allowedCompanyIds: new Set(),
5
+ };
6
+ // Denies everything: a limited scope with an empty allow-list. Used when the
7
+ // viewer's access can't be resolved.
8
+ const DENY_ALL = {
9
+ isLimited: true,
10
+ allowedCompanyIds: new Set(),
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
17
+ // canAccessCompanies) instead of another workspace member's data. Callers that
18
+ // prefer to surface a transient failure should catch it upstream and retry.
19
+ //
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) {
23
+ try {
24
+ // Same narrowing as requireCompanyScope: a session token string on the
25
+ // legacy path, an already-authenticated instance on the bearer path.
26
+ const assembly = typeof tokenOrAssembly === 'string' || tokenOrAssembly === undefined
27
+ ? await getAssembly(tokenOrAssembly)
28
+ : tokenOrAssembly;
29
+ 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
+ };
36
+ }
37
+ catch {
38
+ return DENY_ALL;
39
+ }
40
+ }
41
+ // True when the viewer may see a resource belonging to any of `companyIds`.
42
+ //
43
+ // Mirrors the platform's own users.User.IsAccessAllowed: access is company-scoped,
44
+ // so a limited viewer passes iff at least one of the resource's companies is on
45
+ // their allow-list. Every client belongs to at least one company (real or
46
+ // placeholder), which is what makes the company check sufficient for clients too.
47
+ //
48
+ // A resource with no companies is denied to a limited viewer: there is nothing to
49
+ // match against the allow-list, and guessing would leak.
50
+ export function canAccessCompanies(scope, companyIds) {
51
+ if (!scope.isLimited)
52
+ return true;
53
+ if (!companyIds || companyIds.length === 0)
54
+ return false;
55
+ return companyIds.some((id) => scope.allowedCompanyIds.has(id));
56
+ }
57
+ // Convenience for the common case of filtering client records. Generic over the
58
+ // caller's row type because apps filter reshaped client objects (joined, picked,
59
+ // extended) rather than raw SDK records; the constraint names the only field read.
60
+ export function canAccessClient(scope, client) {
61
+ return canAccessCompanies(scope, client.companyIds);
62
+ }
package/dist/server.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import 'server-only';
2
2
  export * from './assembly.js';
3
3
  export * from './auth.js';
4
+ export * from './client-access.js';
4
5
  export * from './mint-bearer.js';
5
6
  export * from './nango.js';
6
7
  export * from './app-storage.js';
package/dist/server.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import 'server-only';
6
6
  export * from './assembly.js';
7
7
  export * from './auth.js';
8
+ export * from './client-access.js';
8
9
  export * from './mint-bearer.js';
9
10
  export * from './nango.js';
10
11
  export * from './app-storage.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.2-rc.3",
3
+ "version": "0.0.2",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -355,7 +355,7 @@ Each action route is then just the handler, served at the record's `baseUrl`:
355
355
 
356
356
  ```ts
357
357
  // app/api/assembly/time-entries/route.ts — the record's baseUrl
358
- import { automationActionRoute, ActionError } from '@assembly-js/app-foundation/server-actions';
358
+ import { automationActionRoute, ActionError } from '@/lib/assembly-actions';
359
359
  import { db } from '@/lib/db';
360
360
  import { timeEntries } from '@/lib/schema';
361
361
 
@@ -423,7 +423,7 @@ Serve the route with the same wrapper as actions — its token check is what aut
423
423
 
424
424
  ```ts
425
425
  // app/api/assembly/projects/route.ts — the options record's baseUrl
426
- import { automationActionRoute } from '@assembly-js/app-foundation/server-actions';
426
+ import { automationActionRoute } from '@/lib/assembly-actions';
427
427
  import { db } from '@/lib/db';
428
428
  import { projects } from '@/lib/schema';
429
429
  import { eq } from 'drizzle-orm';
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: assembly-avatar-sub
3
- description: Use when building a list or table that shows clients alongside their companies and a client can belong to multiple companies. Covers the row-duplication + AvatarSub pattern for disambiguating which company a row represents.
3
+ description: Use whenever a client's name or avatar is rendered in a list, table, card, or detail header — including a table of the app's own records (reports, requests, tasks, invoices) that merely reference a client, not just a client directory. Clients belong to many companies, so a client shown without its company is ambiguous. Covers the AvatarSub company badge and the row-duplication pattern via expandClientsByCompany.
4
4
  ---
5
5
 
6
6
  `AvatarSub` renders a single nested sub-avatar overlaid at the bottom-right of the parent avatar. It always contains exactly one `Avatar`.
@@ -17,20 +17,33 @@ export PATH="$HOME/.bun/bin:$PATH" && bunx shadcn@latest add @assembly-ui/avatar
17
17
 
18
18
  ## When to use
19
19
 
20
- A client belongs to 2+ companies and the list needs to show which company each row represents. The pattern is **row duplication** — show the client once per company, each row with a single `AvatarSub` for that company.
20
+ Anywhere a client's name or avatar appears, the company it belongs to is part of its identity — a client can belong to many companies, so a bare name is ambiguous. Two different situations, and picking the wrong one is the usual mistake:
21
+
22
+ **Case A — a list OF clients** (a client directory, a picker, a "who has access" table). One client can legitimately occupy several rows, so use **row duplication**: one row per company, each with a single `AvatarSub`.
21
23
 
22
24
  - **0 or 1 company → no sub-avatar.** No disambiguation needed.
23
25
  - **2+ companies → one row per company**, each with a single `AvatarSub`.
24
26
 
27
+ **Case B — a list of the app's own records that reference a client** (reports, requests, tasks, invoices, documents — the far more common table). **Do NOT duplicate rows**: a record belongs to exactly one client-and-company pair, so duplicating it would invent records that don't exist. Instead render a single `AvatarSub` for the company the record is scoped to, read from the record's own `companyId`:
28
+
29
+ ```tsx
30
+ const company = row.companyId ? companiesMap.get(row.companyId) : undefined;
31
+ // …render the client avatar with a single AvatarSub for `company`
32
+ ```
33
+
34
+ If a record's rows are scoped to a company in some cases and to a client in others, badge the client-scoped ones with their `companyId` and leave a company-scoped row's own avatar unbadged — it is already unambiguous.
35
+
36
+ Skipping the badge in Case B is the most common miss: the table looks fine until two clients share a name, or the same client appears under two companies, and staff cannot tell the rows apart.
37
+
25
38
  ## Anti-patterns — never do any of these
26
39
 
27
40
  - Do NOT render multiple `AvatarSub` elements on the same avatar.
28
41
  - Do NOT use `AvatarGroup` of small company avatars next to or on top of a client avatar.
29
42
  - Do NOT show all of a client's companies on a single row with stacked badges.
30
43
 
31
- The sub-avatar disambiguates *which* company a row represents, not *how many* companies a client has. The only correct multi-company pattern is row duplication.
44
+ The sub-avatar disambiguates *which* company a row represents, not *how many* companies a client has. In Case A, row duplication is the only correct way to show a multi-company client; in Case B the record already names its company, so one badge per row is correct and duplication is wrong.
32
45
 
33
- ## Row-duplication pattern
46
+ ## Row-duplication pattern (Case A)
34
47
 
35
48
  Use `expandClientsByCompany` — do NOT hand-roll the expansion. It returns one row per company for multi-company clients (each with its own `displayCompanyId`) and a single `displayCompanyId: null` row for clients with 0 or 1 companies.
36
49
 
@@ -30,30 +30,36 @@ On `retrieveInternalUser` / `listInternalUsers`, each internal user may carry:
30
30
 
31
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
32
 
33
- The session token carries only `internalUserId` — not the access fields. Load the viewer's scope once per request when you need it:
33
+ ## Pre-built helpers
34
+
35
+ `@assembly-js/app-foundation/server` exports these — import them, do not rewrite the check.
34
36
 
35
37
  ```ts
38
+ import {
39
+ getSessionIdentity,
40
+ getClientAccessScope,
41
+ canAccessCompanies,
42
+ canAccessClient,
43
+ } from '@assembly-js/app-foundation/server';
44
+
36
45
  const identity = await getSessionIdentity();
37
46
  if (!identity?.internalUserId) return; // not an internal viewer
38
47
 
39
- const viewer = await assembly.retrieveInternalUser({ id: identity.internalUserId });
40
- const isLimited = viewer.isClientAccessLimited === true;
41
- const accessList = viewer.companyAccessList ?? [];
48
+ // The session token carries only internalUserId, never the access fields, so
49
+ // resolve the scope once per request and reuse it.
50
+ const scope = await getClientAccessScope(identity.internalUserId);
42
51
 
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
+ canAccessClient(scope, client); // a client row (reads client.companyIds)
53
+ canAccessCompanies(scope, [company.id]); // any company-scoped resource
52
54
  ```
53
55
 
56
+ `getClientAccessScope` **fails closed**: if the viewer's access can't be resolved it denies
57
+ everything rather than falling back to full access. Do not catch and widen that to
58
+ "unrestricted" — an unverified viewer must never receive a full-access user's visibility.
59
+
54
60
  ### What to scope
55
61
 
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.
62
+ - **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` / `canAccessCompanies`. Do not assume the platform API filtered the list for you.
57
63
  - **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
64
  - **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
65
  - **Fan-out to other internal users** (notifications, assignments) — filter recipients by their access to the relevant client/company.