@assembly-js/app-foundation 0.0.28 → 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
@@ -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.28",
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 {