@assembly-js/app-foundation 0.0.2-rc.3 → 0.0.4
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 +32 -19
- package/bin/check-route-reachability.mjs +129 -0
- package/bin/check-token-usage.mjs +128 -0
- package/bin/push-schema.mjs +70 -0
- package/bin/validate-manifest.mjs +1151 -0
- package/dist/client-access.d.ts +10 -0
- package/dist/client-access.js +62 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +1 -0
- package/dist/pagination.d.ts +4 -0
- package/dist/pagination.js +40 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/package.json +5 -3
- package/skills/assembly-ai/SKILL.md +1 -1
- package/skills/assembly-automations/SKILL.md +36 -6
- package/skills/assembly-avatar-sub/SKILL.md +17 -4
- package/skills/assembly-client-users/SKILL.md +7 -3
- package/skills/assembly-companies/SKILL.md +41 -1
- package/skills/assembly-internal-users/SKILL.md +26 -16
- package/skills/assembly-native-resources/SKILL.md +4 -2
- package/skills/assembly-notifications/SKILL.md +6 -8
- package/skills/assembly-user-company-selector/SKILL.md +3 -1
|
@@ -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/client.d.ts
CHANGED
package/dist/client.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
export * from './session-exchange.js';
|
|
9
9
|
export * from './network-capture.js';
|
|
10
10
|
export * from './client-companies.js';
|
|
11
|
+
export * from './pagination.js';
|
|
11
12
|
export * from './theme.js';
|
|
12
13
|
// Pure data-shaping over resolver responses — no imports, no secrets, no
|
|
13
14
|
// next/headers. It lives here rather than in ./server because <ResourcePicker>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collects every page of a cursor-paginated SDK list call.
|
|
3
|
+
*
|
|
4
|
+
* Every `list*` method returns the same envelope — `{ data, nextToken }` — so one
|
|
5
|
+
* generic walker covers all of them; the alternative is each app hand-rolling the
|
|
6
|
+
* same do/while, which is how a first-page-only list ships looking correct.
|
|
7
|
+
*
|
|
8
|
+
* `data` is nullable in the SDK's own types, so a null page contributes nothing
|
|
9
|
+
* rather than throwing on spread. `limit` is the caller's to pass: the documented
|
|
10
|
+
* range is 1–100 and enforcement varies by endpoint (some reject higher values),
|
|
11
|
+
* so 100 is the one value that is both safe everywhere and 5x cheaper per walk
|
|
12
|
+
* than the ~20 default.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const companies = await listAll((nextToken) =>
|
|
16
|
+
* assembly.listCompanies({ isPlaceholder: false, nextToken, limit: 100 }),
|
|
17
|
+
* );
|
|
18
|
+
*/
|
|
19
|
+
// A cursor that never goes absent — an API bug, or a caller ignoring the
|
|
20
|
+
// nextToken argument — would otherwise spin forever. 1000 pages at limit: 100
|
|
21
|
+
// is 100k records, far past any real workspace, so tripping this means the
|
|
22
|
+
// walk is broken. Throwing is deliberate: returning what was collected so far
|
|
23
|
+
// would silently truncate the list, which is the bug this helper exists to
|
|
24
|
+
// prevent.
|
|
25
|
+
const MAX_PAGES = 1000;
|
|
26
|
+
export async function listAll(fetchPage) {
|
|
27
|
+
const all = [];
|
|
28
|
+
let cursor;
|
|
29
|
+
let pages = 0;
|
|
30
|
+
do {
|
|
31
|
+
if (++pages > MAX_PAGES) {
|
|
32
|
+
throw new Error(`listAll walked more than ${MAX_PAGES} pages without the cursor ending — is the nextToken being passed through?`);
|
|
33
|
+
}
|
|
34
|
+
const page = await fetchPage(cursor);
|
|
35
|
+
if (page.data)
|
|
36
|
+
all.push(...page.data);
|
|
37
|
+
cursor = page.nextToken || undefined;
|
|
38
|
+
} while (cursor);
|
|
39
|
+
return all;
|
|
40
|
+
}
|
package/dist/server.d.ts
CHANGED
package/dist/server.js
CHANGED
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assembly-js/app-foundation",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "Versioned foundation helpers and build context for Assembly generated apps",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
7
|
"dist",
|
|
8
|
+
"bin",
|
|
8
9
|
"skills",
|
|
9
10
|
"CLAUDE.md",
|
|
10
11
|
"render-context.mjs"
|
|
@@ -22,8 +23,9 @@
|
|
|
22
23
|
"scripts": {
|
|
23
24
|
"build": "tsc && tsc-alias",
|
|
24
25
|
"test": "bun test",
|
|
25
|
-
"prepublishOnly": "npm run build && npm run verify:dist",
|
|
26
|
-
"verify:dist": "node scripts/verify-dist-resolves.mjs"
|
|
26
|
+
"prepublishOnly": "npm run build && npm run verify:dist && npm run verify:bin",
|
|
27
|
+
"verify:dist": "node scripts/verify-dist-resolves.mjs",
|
|
28
|
+
"verify:bin": "node scripts/verify-bin.mjs"
|
|
27
29
|
},
|
|
28
30
|
"peerDependencies": {
|
|
29
31
|
"@assembly-js/node-sdk": ">=4.2.3-beta.2",
|
|
@@ -16,7 +16,7 @@ The proxy mirrors OpenAI's API shape. Call any OpenAI endpoint path via:
|
|
|
16
16
|
Create a helper in `lib/ai.ts` that uses the existing Assembly SDK instance:
|
|
17
17
|
|
|
18
18
|
```typescript
|
|
19
|
-
import { getAssembly } from '
|
|
19
|
+
import { getAssembly } from '@assembly-js/app-foundation/server';
|
|
20
20
|
|
|
21
21
|
export async function chatCompletion(messages: Array<{ role: string; content: string }>, options?: { model?: string; stream?: boolean }) {
|
|
22
22
|
const assembly = await getAssembly();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<!-- feature:api-resources -->
|
|
2
2
|
---
|
|
3
3
|
name: assembly-automations
|
|
4
|
-
description: Use when the request
|
|
4
|
+
description: Use when the request describes a workflow that integrates with the Assembly platform — messaging a client, updating a CRM custom field, sending a contract or form, sharing a file, anything landing on Assembly's own data rather than only the app's tables — and it should happen in reaction to something happening in the app rather than because the user pressed a button. "When a time entry is logged, message the contact" and "completing a project should update the client's status field" both qualify. Both halves are required, and the word "automation" is not: a reaction that only writes the app's own tables is ordinary app code, and so is a platform write the user triggers directly in the app UI. Also use when the request asks the app to fire webhooks, drive or react to another tool, or expose operations others can invoke. Declare those events and operations in .assembly/api-resources.json so they appear as automation triggers and actions, emit each declared event at runtime with assembly.sendWebhook, and serve each exposed operation with automationActionRoute. Covers the manifest schema, the two field shapes, event naming, the recipient-id contract, trigger configuration, dropdown option sources for action inputs, runtime event emission, and the action route contract.
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
An app's API contract — the events it emits and the operations workspace automations can invoke — is declared in **`.assembly/api-resources.json`** at the repo root. The platform reads it at publish and registers the app's triggers and actions. Which file owns what: `.assembly/app.json` = how the platform runs the app (surfaces, secrets, integrations); `.assembly/api-resources.json` = the API contract the app exposes. Never mix their keys.
|
|
@@ -10,14 +10,38 @@ Declaring a webhook only registers the trigger — the running app must also **e
|
|
|
10
10
|
|
|
11
11
|
## When to declare it
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Two things have to be true at once. Neither one alone is the test, and the word "automation" is not the test either:
|
|
14
|
+
|
|
15
|
+
1. **The request is reactive** — something should happen *because* an in-app event happened, not because the user pressed a button and is watching.
|
|
16
|
+
2. **The effect lands on the Assembly platform** — a message, a client or company custom field, a contract, a form, a file. Not the app's own tables.
|
|
17
|
+
|
|
18
|
+
Both hold, so both of these qualify:
|
|
19
|
+
|
|
20
|
+
- "when i log a time entry, send a message to the relevant contact"
|
|
21
|
+
- "completing a project should message the client and update their status field"
|
|
22
|
+
|
|
23
|
+
**Only the first holds here, so declare nothing:** "when a task is marked done, move it to the archive column and recalculate the totals." It is reactive and it is shaped like a workflow, but every effect is in the app's own database. That is ordinary app code — write it at the mutation site.
|
|
24
|
+
|
|
25
|
+
**Only the second holds here, so declare nothing:** a "Message client" button in the app. The effect is a platform write, but the user pressed it and is watching it happen. Call the SDK and be done.
|
|
26
|
+
|
|
27
|
+
Also use this skill when the request asks the app to fire webhooks, drive or react to another tool, or expose operations other tools or staff workflows can invoke.
|
|
14
28
|
|
|
15
29
|
Given that, declare whichever of these the request implies:
|
|
16
30
|
|
|
17
|
-
- **Domain events**: state changes
|
|
31
|
+
- **Domain events**: state changes something should react to ("time entry logged", "request approved"). Each becomes an automation trigger and a workspace webhook event.
|
|
18
32
|
- **Staff-invokable operations**: writes an automation should be able to perform ("create a time entry", "assign a task"). Each becomes an automation action.
|
|
19
33
|
|
|
20
|
-
**
|
|
34
|
+
**Absent both conditions, the file must not exist** — its absence means "this app registers no API", which is the right answer for a dashboard, a viewer, a report, and any app nobody asked to react to anything. Declaring anyway is not free: every exposed operation shows up as an action in front of every workspace admin in the automation builder, and every declared event has to be emitted at runtime or automations built on it silently never run.
|
|
35
|
+
|
|
36
|
+
### Let the automation perform the "then"
|
|
37
|
+
|
|
38
|
+
The automation builder offers native actions over Assembly's own modules — messaging, client and company custom fields, contracts, forms, files — alongside any actions apps in the workspace have declared. Treat that set as growing, not fixed: don't assume an effect is impossible because you can't name the action.
|
|
39
|
+
|
|
40
|
+
**When the "then" is one of Assembly's own module effects, declare and emit the trigger and do not hand-roll the SDK call at the mutation site.** The platform owns retry, run history, and the workspace's ability to change the behaviour later without a rebuild; a hard-coded call has none of that, and a user who then builds the automation gets the effect twice. Most workflows in this bucket need **no `apis[]` at all** — the trigger is the app's whole job. Declare an api only when something should be able to invoke *the app*.
|
|
41
|
+
|
|
42
|
+
**When the "then" is a notification, the app sends it.** `createNotification` is not an automation action. Call it directly and see the `assembly-notifications` skill; that skill owns notifications end to end, and there is nothing to defer here.
|
|
43
|
+
|
|
44
|
+
**When you can't tell whether an action exists for the "then" — creating a task is the common case — ask before you build.** Neither answer is yours to pick: declaring a trigger nobody can attach an action to leaves the user staring at an empty automation builder, and hard-coding it silently forecloses the automation they asked for. They can see the current action list and you can't, so put the fork to them in one question, in their terms — whether the app should do this itself now, or you declare the trigger for them to wire up in Automations. Ask *that*; do not spend the question on a field name you can discover at runtime or on message copy you can draft.
|
|
21
45
|
|
|
22
46
|
## File format
|
|
23
47
|
|
|
@@ -355,7 +379,7 @@ Each action route is then just the handler, served at the record's `baseUrl`:
|
|
|
355
379
|
|
|
356
380
|
```ts
|
|
357
381
|
// app/api/assembly/time-entries/route.ts — the record's baseUrl
|
|
358
|
-
import { automationActionRoute, ActionError } from '
|
|
382
|
+
import { automationActionRoute, ActionError } from '@/lib/assembly-actions';
|
|
359
383
|
import { db } from '@/lib/db';
|
|
360
384
|
import { timeEntries } from '@/lib/schema';
|
|
361
385
|
|
|
@@ -423,7 +447,7 @@ Serve the route with the same wrapper as actions — its token check is what aut
|
|
|
423
447
|
|
|
424
448
|
```ts
|
|
425
449
|
// app/api/assembly/projects/route.ts — the options record's baseUrl
|
|
426
|
-
import { automationActionRoute } from '
|
|
450
|
+
import { automationActionRoute } from '@/lib/assembly-actions';
|
|
427
451
|
import { db } from '@/lib/db';
|
|
428
452
|
import { projects } from '@/lib/schema';
|
|
429
453
|
import { eq } from 'drizzle-orm';
|
|
@@ -448,6 +472,12 @@ export const GET = automationActionRoute(async (_input, { workspaceId }) => {
|
|
|
448
472
|
- **`trigger.icon`, when set, must be one of the names in the icon list above.** Unknown names fail the publish; an omitted icon renders a generic default.
|
|
449
473
|
- **Renaming breaks saved automations.** An action's identity derives from the api's `name` and a trigger's from `eventName`; renaming either orphans every automation a workspace built on it. Pick names as if they're permanent.
|
|
450
474
|
|
|
475
|
+
## Telling the user
|
|
476
|
+
|
|
477
|
+
**Call it an automation, not a webhook.** In the product this feature is Automations; "webhook" is the wire format underneath it. `sendWebhook` and the `webhooks` manifest key keep their names in code — this is about what you say in chat. "I added a *Time entry logged* trigger you can build automations on" lands; "I set up a webhook" reads as plumbing nobody asked for.
|
|
478
|
+
|
|
479
|
+
**Say what is left for them to do.** Declaring a trigger does not create the automation — the workspace still has to build it. When you finish, name the trigger you registered, name **each** automation they need to build, and point them at the **Automations** module (`/automations` in the dashboard). Count the actions the request asked for, not the triggers: "completing a project should message the client and update their status field" is one trigger and two actions, so say both. Skip this and the app looks like it ignored the request: the trigger exists, nothing is wired to it, and nothing happens.
|
|
480
|
+
|
|
451
481
|
## Validation
|
|
452
482
|
|
|
453
483
|
**Run `bun run validate:manifest` after every edit to this file, and never finish the task on a failing run.** It applies the same rules as the publish gate and names the exact field and problem; skip it and the first signal is a failed publish the user has to bring back to you. `bun run build` runs it first for the same reason.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: assembly-avatar-sub
|
|
3
|
-
description: Use
|
|
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
|
-
|
|
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.
|
|
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
|
|
|
@@ -10,13 +10,17 @@ A client belongs to one or more companies (see `assembly-companies` skill — th
|
|
|
10
10
|
## Identify the current viewer
|
|
11
11
|
|
|
12
12
|
```ts
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
import { getSessionIdentity } from '@assembly-js/app-foundation/server';
|
|
14
|
+
|
|
15
|
+
const identity = await getSessionIdentity();
|
|
16
|
+
if (identity?.clientId) {
|
|
17
|
+
const me = await assembly.retrieveClient({ id: identity.clientId });
|
|
16
18
|
// ...
|
|
17
19
|
}
|
|
18
20
|
```
|
|
19
21
|
|
|
22
|
+
Never call `assembly.getTokenPayload()` for this — see `assembly-session-token`.
|
|
23
|
+
|
|
20
24
|
(`internalUserId` is set instead for staff — see `assembly-internal-users` skill.)
|
|
21
25
|
|
|
22
26
|
## SDK methods
|
|
@@ -8,6 +8,35 @@ Companies group `Client` users. The relationship is **many-to-many**:
|
|
|
8
8
|
- A company can have an unlimited number of clients (no enforced cap).
|
|
9
9
|
- A client can belong to many companies — typically up to ~50 in practice.
|
|
10
10
|
|
|
11
|
+
## Companies are a first-class entity
|
|
12
|
+
|
|
13
|
+
A company exists independently of its clients. **A company with zero clients is a real company and must appear in any list of companies** — workspaces routinely create a company before inviting anyone into it, and that company is not a draft or a stub.
|
|
14
|
+
|
|
15
|
+
So always fetch companies directly, and **page all the way through** — `listCompanies` is cursor-paginated, so treating the first response as the whole set drops companies for exactly the same reason deriving from clients does. `@assembly-js/app-foundation/client` exports `listAll` for that walk — import it, don't rewrite it. Pass `isPlaceholder: false` explicitly: it's the app-builder default, but stating it keeps the intent readable and survives the app being pointed at a non-app-builder token.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { listAll } from '@assembly-js/app-foundation/client';
|
|
19
|
+
|
|
20
|
+
const companies = await listAll((nextToken) =>
|
|
21
|
+
assembly.listCompanies({ isPlaceholder: false, nextToken, limit: 100 }),
|
|
22
|
+
);
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`listAll` works for any `list*` method — they all return the same `{ data, nextToken }` envelope — so use it wherever a complete list matters rather than hand-rolling a `do/while` per call site.
|
|
26
|
+
|
|
27
|
+
**Never derive the company set from clients.** Mapping `client.companyIds` through `retrieveCompany` and treating the result as "the companies" silently drops every company nobody is assigned to yet, which users report as "my company disappeared." Fetch clients and companies as two independent paginated calls — in parallel — and don't intersect one against the other:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { listAll } from '@assembly-js/app-foundation/client';
|
|
31
|
+
|
|
32
|
+
const [clients, companies] = await Promise.all([
|
|
33
|
+
listAll((nextToken) => assembly.listClients({ nextToken, limit: 100 })),
|
|
34
|
+
listAll((nextToken) => assembly.listCompanies({ isPlaceholder: false, nextToken, limit: 100 })),
|
|
35
|
+
]);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Fetching a *specific* client's companies via `client.companyIds` is still correct when the UI is scoped to that one client (e.g. a company switcher for the signed-in client) — see `assembly-clients-companies` skill. That is a different question from "what companies exist."
|
|
39
|
+
|
|
11
40
|
## Use `companyIds`, not `companyId`
|
|
12
41
|
|
|
13
42
|
The canonical relationship is the `companyIds: string[]` array on a Client. The singular `companyId` field is **deprecated since SDK v3.15.0** and exists only for backward compat. Always prefer the array:
|
|
@@ -21,7 +50,7 @@ Including the deprecated `companyId` in `updateClient` will trigger a re-assignm
|
|
|
21
50
|
## SDK methods
|
|
22
51
|
|
|
23
52
|
- `createCompany({ requestBody })`
|
|
24
|
-
- `listCompanies({ name, isPlaceholder, nextToken, limit })`
|
|
53
|
+
- `listCompanies({ name, isPlaceholder, nextToken, limit })` — cursor-paginated; walk it with `listAll` for a complete list
|
|
25
54
|
- `retrieveCompany({ id })`
|
|
26
55
|
- `updateCompany({ id, requestBody })` — see `assembly-mutations` skill
|
|
27
56
|
- `deleteCompany({ id })`
|
|
@@ -32,6 +61,17 @@ Some `Company` records have `isPlaceholder: true`. They exist so the platform ca
|
|
|
32
61
|
|
|
33
62
|
**For app-builder apps the platform API excludes placeholders from `listCompanies` by default**, so you don't need to filter them yourself — a plain `listCompanies()` already returns only real companies. (Outside the app builder, the public API still returns all companies unless you pass `isPlaceholder: false`.)
|
|
34
63
|
|
|
64
|
+
### Placeholder is the only company filter — never filter by client count
|
|
65
|
+
|
|
66
|
+
These two are opposites and are easy to conflate:
|
|
67
|
+
|
|
68
|
+
| | Show it? |
|
|
69
|
+
|---|---|
|
|
70
|
+
| Real, named company with **zero clients** | **Yes** — it's a real company (see "Companies are a first-class entity") |
|
|
71
|
+
| **Placeholder** company (stand-in for a client with no employer) | No — `isPlaceholder: false` already excludes it |
|
|
72
|
+
|
|
73
|
+
A placeholder is not "an empty company." It's the reverse: it exists *because* a client needed a company-shaped reference. So `isPlaceholder: false` is the right and only filter — do not additionally drop companies for having no clients, no members, or no associated records.
|
|
74
|
+
|
|
35
75
|
### When to include placeholders
|
|
36
76
|
|
|
37
77
|
Pass `isPlaceholder: true` explicitly to fetch placeholders (this returns *only* placeholders, so merge with the default list if you need both). Useful for:
|
|
@@ -8,10 +8,14 @@ Internal users are the **business team** running the Assembly workspace. They si
|
|
|
8
8
|
## Identify the current viewer
|
|
9
9
|
|
|
10
10
|
```ts
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
import { getSessionIdentity } from '@assembly-js/app-foundation/server';
|
|
12
|
+
|
|
13
|
+
const identity = await getSessionIdentity();
|
|
14
|
+
const isInternal = !!identity?.internalUserId;
|
|
13
15
|
```
|
|
14
16
|
|
|
17
|
+
Never call `assembly.getTokenPayload()` for this — see `assembly-session-token`.
|
|
18
|
+
|
|
15
19
|
If `internalUserId` is set, the current viewer is staff. (`clientId` is set for client viewers — see `assembly-client-users` skill.)
|
|
16
20
|
|
|
17
21
|
## SDK methods
|
|
@@ -30,30 +34,36 @@ On `retrieveInternalUser` / `listInternalUsers`, each internal user may carry:
|
|
|
30
34
|
|
|
31
35
|
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
36
|
|
|
33
|
-
|
|
37
|
+
## Pre-built helpers
|
|
38
|
+
|
|
39
|
+
`@assembly-js/app-foundation/server` exports these — import them, do not rewrite the check.
|
|
34
40
|
|
|
35
41
|
```ts
|
|
42
|
+
import {
|
|
43
|
+
getSessionIdentity,
|
|
44
|
+
getClientAccessScope,
|
|
45
|
+
canAccessCompanies,
|
|
46
|
+
canAccessClient,
|
|
47
|
+
} from '@assembly-js/app-foundation/server';
|
|
48
|
+
|
|
36
49
|
const identity = await getSessionIdentity();
|
|
37
50
|
if (!identity?.internalUserId) return; // not an internal viewer
|
|
38
51
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
function canAccessCompany(companyId: string): boolean {
|
|
44
|
-
if (!isLimited) return true;
|
|
45
|
-
return accessList.includes(companyId);
|
|
46
|
-
}
|
|
52
|
+
// The session token carries only internalUserId, never the access fields, so
|
|
53
|
+
// resolve the scope once per request and reuse it.
|
|
54
|
+
const scope = await getClientAccessScope(identity.internalUserId);
|
|
47
55
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return (client.companyIds ?? []).some((id) => accessList.includes(id));
|
|
51
|
-
}
|
|
56
|
+
canAccessClient(scope, client); // a client row (reads client.companyIds)
|
|
57
|
+
canAccessCompanies(scope, [company.id]); // any company-scoped resource
|
|
52
58
|
```
|
|
53
59
|
|
|
60
|
+
`getClientAccessScope` **fails closed**: if the viewer's access can't be resolved it denies
|
|
61
|
+
everything rather than falling back to full access. Do not catch and widen that to
|
|
62
|
+
"unrestricted" — an unverified viewer must never receive a full-access user's visibility.
|
|
63
|
+
|
|
54
64
|
### What to scope
|
|
55
65
|
|
|
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` / `
|
|
66
|
+
- **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
67
|
- **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
68
|
- **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
69
|
- **Fan-out to other internal users** (notifications, assignments) — filter recipients by their access to the relevant client/company.
|
|
@@ -11,10 +11,12 @@ Discover instead. The pattern is always the same two steps: **list, then match o
|
|
|
11
11
|
|
|
12
12
|
## Discovering a form
|
|
13
13
|
|
|
14
|
-
A form's display name is on `fields`, not on the form itself:
|
|
14
|
+
A form's display name is on `fields`, not on the form itself. Walk every page — matching by name against only the first page reports the form as missing once the workspace has more than a page of forms:
|
|
15
15
|
|
|
16
16
|
```ts
|
|
17
|
-
|
|
17
|
+
import { listAll } from '@assembly-js/app-foundation/client';
|
|
18
|
+
|
|
19
|
+
const forms = await listAll((nextToken) => assembly.listForms({ nextToken, limit: 100 }));
|
|
18
20
|
const intakeForm = forms.find(
|
|
19
21
|
(f) => f.fields?.name?.toLowerCase().includes('intake'), // match the user's wording
|
|
20
22
|
);
|
|
@@ -171,16 +171,14 @@ await assembly.createNotification({
|
|
|
171
171
|
|
|
172
172
|
To notify multiple recipients (e.g., several internal users), make one `createNotification` call per recipient. See "Apps with multiple notification types" below.
|
|
173
173
|
|
|
174
|
-
**`listInternalUsers` / `listClients` are paginated** — one call returns only the first page. To notify *everyone*,
|
|
174
|
+
**`listInternalUsers` / `listClients` are paginated** — one call returns only the first page. To notify *everyone*, collect all recipients before fanning out, or you'll silently skip later-page recipients. `listAll` from `@assembly-js/app-foundation/client` does the walk — import it, don't rewrite it:
|
|
175
175
|
|
|
176
176
|
```ts
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
nextToken = res.nextToken ?? undefined;
|
|
183
|
-
} while (nextToken);
|
|
177
|
+
import { listAll } from '@assembly-js/app-foundation/client';
|
|
178
|
+
|
|
179
|
+
const recipients = await listAll((nextToken) =>
|
|
180
|
+
assembly.listInternalUsers({ nextToken, limit: 100 }),
|
|
181
|
+
);
|
|
184
182
|
await sendThrottled(recipients, (u) =>
|
|
185
183
|
assembly.createNotification({ /* ...recipientInternalUserId: u.id... */ }),
|
|
186
184
|
);
|
|
@@ -49,7 +49,9 @@ const internalUserEntities: Entity[] = internalUsers.map(u => ({
|
|
|
49
49
|
}));
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
**
|
|
52
|
+
**Pass every company, fetched with `listCompanies({ isPlaceholder: false })` and paged in full via `listAll`** — see `assembly-companies` skill for the fetch and why the complete list matters. Never pass `companies={[]}`, and never build the prop from clients' `companyIds`.
|
|
53
|
+
|
|
54
|
+
Every company a client's `companyIds` references must be in the prop, or its badge can't render: the component expands a client into one entry per company and shows the company avatar beside each, so a missing company object leaves duplicate, indistinguishable client rows. That's a floor, not the set — the prop takes all companies, not just the referenced ones.
|
|
53
55
|
|
|
54
56
|
Always include `avatarFallbackColor` from the SDK's `fallbackColor` field — without it, avatar fallbacks render as plain gray instead of the user's assigned color.
|
|
55
57
|
|