@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 +1 -1
- package/dist/client-access.d.ts +5 -0
- package/dist/client-access.js +16 -15
- package/package.json +2 -2
- package/skills/assembly-internal-users/SKILL.md +20 -2
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
|
package/dist/client-access.d.ts
CHANGED
|
@@ -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 {
|
package/dist/client-access.js
CHANGED
|
@@ -9,17 +9,16 @@ const DENY_ALL = {
|
|
|
9
9
|
isLimited: true,
|
|
10
10
|
allowedCompanyIds: new Set(),
|
|
11
11
|
};
|
|
12
|
-
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
|
21
|
-
// this costs one `retrieveInternalUser` per request that needs it.
|
|
22
|
-
export async function
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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 {
|