@assembly-js/app-foundation 0.0.2-rc.1 → 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 +1 -1
- package/dist/client-access.d.ts +10 -0
- package/dist/client-access.js +62 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/package.json +1 -1
- package/skills/assembly-avatar-sub/SKILL.md +17 -4
- package/skills/assembly-integrations/SKILL.md +15 -15
- package/skills/assembly-internal-users/SKILL.md +20 -14
- package/skills/assembly-notifications/SKILL.md +2 -2
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` —
|
|
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
package/dist/server.js
CHANGED
package/package.json
CHANGED
|
@@ -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
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: assembly-integrations
|
|
3
|
-
description: Use BEFORE declaring requiredIntegrations in .assembly/app.json and before writing any call through getIntegration() from
|
|
3
|
+
description: Use BEFORE declaring requiredIntegrations in .assembly/app.json and before writing any call through getIntegration() from @assembly-js/app-foundation/server (Slack and other OAuth providers — supported list inside). Covers manifest declaration, the broker client, per-provider gotchas (pagination, IDs vs names, membership/join), and ConnectorError handling.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
## How it works
|
|
7
7
|
|
|
8
8
|
When your app needs to talk to a third-party provider's API, declare the provider in `requiredIntegrations` in `.assembly/app.json`. The workspace admin connects the provider once via an OAuth popup; Assembly stores the connection through Nango.
|
|
9
9
|
|
|
10
|
-
At runtime you call the provider through `getIntegration(provider)` from
|
|
10
|
+
At runtime you call the provider through `getIntegration(provider)` from `@assembly-js/app-foundation/server`. Calls go to the Assembly **connector broker** over your app's own authenticated session — you never see, handle, or store OAuth tokens or any Nango credentials, and there are **no per-provider environment variables**. The broker resolves the workspace's connection server-side (from your app's identity) and relays the provider's response back to you.
|
|
11
11
|
|
|
12
12
|
The flow:
|
|
13
13
|
|
|
@@ -47,7 +47,7 @@ Add a top-level `requiredIntegrations` array to `.assembly/app.json`. Each entry
|
|
|
47
47
|
`getIntegration` is **async** and must run **server-side, inside an authenticated request scope** — an API route handler, a server component, or a server action where `getAssembly()` can read the viewer bearer cookie. It authenticates to the broker as the current viewer the same way every other data call does — no `x-assembly-token` header, no token argument. Do **not** call it from a client component, from a background/cron job, or outside a user request — it will fail to authenticate. In practice: call it from a route handler invoked via `authFetch`, or from a server component/action that uses `getAssembly()`.
|
|
48
48
|
|
|
49
49
|
```typescript
|
|
50
|
-
import { getIntegration } from '
|
|
50
|
+
import { getIntegration } from '@assembly-js/app-foundation/server';
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
`getIntegration(provider)` resolves to a thin client:
|
|
@@ -69,7 +69,7 @@ Some action inputs are flagged above as **resource inputs**: the value is the ID
|
|
|
69
69
|
```typescript
|
|
70
70
|
// app/api/slack/post/route.ts
|
|
71
71
|
import { NextRequest, NextResponse } from 'next/server';
|
|
72
|
-
import { getIntegration, slackPostMessage } from '
|
|
72
|
+
import { getIntegration, slackPostMessage } from '@assembly-js/app-foundation/server';
|
|
73
73
|
|
|
74
74
|
export async function POST(request: NextRequest) {
|
|
75
75
|
const { channel, text } = await request.json();
|
|
@@ -89,7 +89,7 @@ export async function POST(request: NextRequest) {
|
|
|
89
89
|
```typescript
|
|
90
90
|
// app/api/slack/history/route.ts
|
|
91
91
|
import { NextRequest, NextResponse } from 'next/server';
|
|
92
|
-
import { getIntegration } from '
|
|
92
|
+
import { getIntegration } from '@assembly-js/app-foundation/server';
|
|
93
93
|
|
|
94
94
|
export async function GET(request: NextRequest) {
|
|
95
95
|
const channelId = request.nextUrl.searchParams.get('channelId') ?? '';
|
|
@@ -110,13 +110,13 @@ provider `Response`.
|
|
|
110
110
|
|
|
111
111
|
When the UI needs the user to choose a provider-side resource (a Slack channel, a Drive folder, a spreadsheet, an Airtable base), do NOT hand-roll the combobox plumbing — the foundation ships both halves:
|
|
112
112
|
|
|
113
|
-
- **`resolveResource(provider, resolverAction, { query?, cursor?, fields, queryInput?, cursorInput?, input? })`** from
|
|
113
|
+
- **`resolveResource(provider, resolverAction, { query?, cursor?, fields, queryInput?, cursorInput?, input? })`** from `@assembly-js/app-foundation/server` — server-side. Runs the resolver action and normalizes the provider's shape to `{ id, label }` options plus `nextCursor`. It returns a serializable `ResourcePage` — broker failures come back as `{ ok: false, error: { code, provider } }` instead of throwing, so the result survives a Server Action boundary (a thrown error is redacted in production). `fields` maps the action's result shape (dot paths allowed) — read it off the action's catalog signature above. Pass `queryInput` only when the action has a server-side search input; otherwise each page is filtered by label locally.
|
|
114
114
|
- **`<ResourcePicker>`** from `@/app/components/resource-picker` — client-side searchable combobox with debounced search, cursor pagination ("Load more"), loading/empty states, and connector-error surfaces (`needs_reauth` renders a reconnect hint instead of an empty list). Store `value.id`, render `value.label`.
|
|
115
115
|
|
|
116
116
|
```typescript
|
|
117
117
|
// app/actions/channels.ts
|
|
118
118
|
'use server';
|
|
119
|
-
import { resolveResource } from '
|
|
119
|
+
import { resolveResource } from '@assembly-js/app-foundation/server';
|
|
120
120
|
|
|
121
121
|
export async function searchChannels(args: { query: string; cursor?: string }) {
|
|
122
122
|
// list-channels returns { conversations, next_cursor? } and has no query
|
|
@@ -131,7 +131,7 @@ export async function searchChannels(args: { query: string; cursor?: string }) {
|
|
|
131
131
|
```tsx
|
|
132
132
|
// client component
|
|
133
133
|
import { ResourcePicker } from '@/app/components/resource-picker';
|
|
134
|
-
import type { ResourceOption } from '
|
|
134
|
+
import type { ResourceOption } from '@assembly-js/app-foundation/client';
|
|
135
135
|
|
|
136
136
|
const [channel, setChannel] = useState<ResourceOption | null>(null);
|
|
137
137
|
<ResourcePicker fetchPage={searchChannels} value={channel} onValueChange={setChannel} placeholder="Search channels…" />
|
|
@@ -139,13 +139,13 @@ const [channel, setChannel] = useState<ResourceOption | null>(null);
|
|
|
139
139
|
|
|
140
140
|
## Slack specifics
|
|
141
141
|
|
|
142
|
-
- **Post messages with `slackPostMessage` from
|
|
143
|
-
- **Resolve channel names with `slackFindChannelId` from
|
|
142
|
+
- **Post messages with `slackPostMessage` from `@assembly-js/app-foundation/server` — never hand-roll `chat.postMessage`.** It resolves a `#name` to the channel ID, posts, and on `not_in_channel` joins the channel and retries once. Slack-level failures come back as `{ ok: false, error }`; on `not_in_channel` render "invite the bot to #channel in Slack" (private channels cannot be self-joined).
|
|
143
|
+
- **Resolve channel names with `slackFindChannelId` from `@assembly-js/app-foundation/server` for every other per-channel call — never hand-roll the lookup.** It handles cursor pagination (a single `conversations.list` call misses channels in large workspaces), name normalization, scope fallback, rate limits, and caching. Use the returned ID for every follow-up call; most Slack methods reject a `#name` with `invalid_arguments`.
|
|
144
144
|
- **Writes need membership, not just visibility.** `slackPostMessage` handles this for posting. For any other write (reactions, uploads), a `not_in_channel` error means join first: the `join-channel` action with the channel ID, then retry once. Private channels cannot be self-joined — they fail `method_not_supported_for_channel_type`, so tell the user to invite the bot instead of retrying.
|
|
145
145
|
- **Scopes:** `chat:write.public` lets posting reach any public channel without joining it, so only the other writes need `join-channel`. Add `groups:read` if the app must find private channels.
|
|
146
146
|
|
|
147
147
|
```typescript
|
|
148
|
-
import { getIntegration, slackFindChannelId } from '
|
|
148
|
+
import { getIntegration, slackFindChannelId } from '@assembly-js/app-foundation/server';
|
|
149
149
|
|
|
150
150
|
const slack = await getIntegration('slack');
|
|
151
151
|
const channelId = await slackFindChannelId(slack, 'general');
|
|
@@ -163,7 +163,7 @@ A connector call throws a **`ConnectorError`** (with `.code`, `.provider`, `.sta
|
|
|
163
163
|
- `invalid_request` — malformed relay input (bad action name, unreadable body). Fix the call site.
|
|
164
164
|
|
|
165
165
|
```typescript
|
|
166
|
-
import { getIntegration } from '
|
|
166
|
+
import { getIntegration } from '@assembly-js/app-foundation/server';
|
|
167
167
|
|
|
168
168
|
try {
|
|
169
169
|
const slack = await getIntegration('slack');
|
|
@@ -178,10 +178,10 @@ try {
|
|
|
178
178
|
|
|
179
179
|
### Checking whether a provider is connected
|
|
180
180
|
|
|
181
|
-
To gate UI on connectivity ("show a Connect prompt if Slack isn't connected"), use the **`isConnected`** helper from
|
|
181
|
+
To gate UI on connectivity ("show a Connect prompt if Slack isn't connected"), use the **`isConnected`** helper from `@assembly-js/app-foundation/server`. It asks the broker's read-only status endpoint for the stored connection state — **no provider call is made**, so it is cheap, has no side effects, and an unrelated call failure can never masquerade as "not connected". Do **not** probe by making a provider call and branching on whether it succeeded — a provider `404`/`400`/`500`, `action_failed`, or `connector_unavailable` all mean the connection **exists** and that call merely failed.
|
|
182
182
|
|
|
183
183
|
```typescript
|
|
184
|
-
import { isConnected } from '
|
|
184
|
+
import { isConnected } from '@assembly-js/app-foundation/server';
|
|
185
185
|
|
|
186
186
|
const slackConnected = await isConnected('slack');
|
|
187
187
|
```
|
|
@@ -189,7 +189,7 @@ const slackConnected = await isConnected('slack');
|
|
|
189
189
|
When the UI needs to distinguish *why* a provider isn't usable (render "Connect" vs "Reconnect"), read the full state from the client's **`status()`** method instead — it returns `{ provider, connected, status }` where `status` is `connected`, `connection_required`, `needs_reauth`, or `needs_reauth_scope`:
|
|
190
190
|
|
|
191
191
|
```typescript
|
|
192
|
-
import { getIntegration } from '
|
|
192
|
+
import { getIntegration } from '@assembly-js/app-foundation/server';
|
|
193
193
|
|
|
194
194
|
const slack = await getIntegration('slack');
|
|
195
195
|
const { connected, status } = await slack.status();
|
|
@@ -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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
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
|
-
|
|
44
|
-
|
|
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` / `
|
|
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.
|
|
@@ -106,11 +106,11 @@ await assembly.createNotification({
|
|
|
106
106
|
});
|
|
107
107
|
```
|
|
108
108
|
|
|
109
|
-
The examples here are **request-scoped** — a user is present, so `getAssembly()` reads the bearer from the request cookie and acts as that viewer. For a **background send with no user in the loop** (a scheduled job or an incoming webhook), there is no bearer cookie: call `getOfflineAssembly()` from
|
|
109
|
+
The examples here are **request-scoped** — a user is present, so `getAssembly()` reads the bearer from the request cookie and acts as that viewer. For a **background send with no user in the loop** (a scheduled job or an incoming webhook), there is no bearer cookie: call `getOfflineAssembly()` from `@assembly-js/app-foundation/server`, which mints an app-scoped **offline bearer** from your API key server-side. In that path `getSessionIdentity()` returns `null`, so choose a `senderId` explicitly (e.g. a system/admin internal user) rather than reading it from identity.
|
|
110
110
|
|
|
111
111
|
```ts
|
|
112
112
|
// app/api/cron/reminders/route.ts — background send, no viewer
|
|
113
|
-
import { getOfflineAssembly } from '
|
|
113
|
+
import { getOfflineAssembly } from '@assembly-js/app-foundation/server';
|
|
114
114
|
|
|
115
115
|
export async function GET() {
|
|
116
116
|
const assembly = await getOfflineAssembly(); // app-scoped bearer, no cookie
|