@assembly-js/app-foundation 0.3.2 → 0.3.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/CHANGELOG.md +7 -0
- package/CLAUDE.md +2 -0
- package/bin/foundation-exports.mjs +2 -2
- package/dist/notifications.d.ts +63 -0
- package/dist/notifications.js +57 -0
- package/package.json +1 -1
- package/skills/assembly-notifications/SKILL.md +44 -31
package/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,13 @@ A patch release never changes what an app's build requires; a minor release may
|
|
|
19
19
|
not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
|
|
20
20
|
`bump: minor` change file.
|
|
21
21
|
|
|
22
|
+
## 0.3.4
|
|
23
|
+
- [guidance] The build agent is now taught to authenticate inbound webhook calls inside the route, fail closed when the secret is unset, and never add an unauthenticated test endpoint.
|
|
24
|
+
|
|
25
|
+
## 0.3.3
|
|
26
|
+
- [new] `notifyInternalUsers` (from `@assembly-js/app-foundation/server`) notifies every internal user, or a given `recipientIds` list, on behalf of the current viewer using the installer client.
|
|
27
|
+
- [guidance] The notifications skill now directs every client-triggered send to staff through `notifyInternalUsers`, since a client's own `getAssembly()` client cannot create notifications or list staff.
|
|
28
|
+
|
|
22
29
|
## 0.3.2
|
|
23
30
|
- [new] The `assembly-api-contract` skill teaches the build agent to write and maintain `.assembly/openapi.json` alongside `app/api` routes.
|
|
24
31
|
|
package/CLAUDE.md
CHANGED
|
@@ -1037,6 +1037,8 @@ async function saveFlavor(clientId: string, flavor: string) {
|
|
|
1037
1037
|
|
|
1038
1038
|
Every state-changing request (POST/PUT/PATCH/DELETE) **must** go through `authFetch` (or a Next server action) — a plain `fetch` is rejected with 403 (CSRF). Never put a token or the bearer in a URL or a manual header; the cookie carries it.
|
|
1039
1039
|
|
|
1040
|
+
In current apps `proxy.ts` lets a request that carries no cookies skip the CSRF check, so an inbound third-party call (a webhook) needs no proxy change; an older `proxy.ts` without that rule still answers 403 to a header-less POST. Authenticate the caller inside the route (signature or shared secret), fail closed when its secret is unset, and never add an unauthenticated test endpoint.
|
|
1041
|
+
|
|
1040
1042
|
#### Route handlers — `getAssemblyForRequest`
|
|
1041
1043
|
|
|
1042
1044
|
```ts
|
|
@@ -29,7 +29,7 @@ export const NON_CLIENT_EXPORTS = new Set([
|
|
|
29
29
|
'FormatAirtableRecordsOpts', 'GenerateAppClientInput', 'GeneratedAppClient', 'GmailSendArgs', 'GmailSendResult', 'IntegrationClient',
|
|
30
30
|
'InternalUserAccess', 'LEGACY_APP_IDS', 'LocationReporter', 'MintFailure', 'MintOutcome', 'MintSessionOptions', 'MintedAppBearer',
|
|
31
31
|
'MintedBearer', 'NETWORK_CAPTURE_MESSAGE_TYPE', 'NetworkCaptureHeaderFingerprint',
|
|
32
|
-
'NetworkCaptureRecord', 'PAINTED_BEACON_EVENT', 'PAINTED_BEACON_PATH',
|
|
32
|
+
'NetworkCaptureRecord', 'NotifyInternalUsersInput', 'PAINTED_BEACON_EVENT', 'PAINTED_BEACON_PATH',
|
|
33
33
|
'PORTAL_ORIGIN_COOKIE_NAME', 'PdfText', 'PrincipalAllow', 'PrincipalKind',
|
|
34
34
|
'Provider', 'ProxyOpts', 'RENEW_OUTCOME_EVENT', 'RenewOutcome', 'ReplaceChildRowsResult',
|
|
35
35
|
'RequestSource', 'ResolveResourceOpts', 'ResourceError', 'ResourceFieldMap',
|
|
@@ -52,7 +52,7 @@ export const NON_CLIENT_EXPORTS = new Set([
|
|
|
52
52
|
'isDocumentRequest', 'isPaintedBeaconPath', 'isSessionExchangeEnabled', 'isUuid',
|
|
53
53
|
'isValidHexColor', 'listAll', 'logCookieOutcome', 'logFailures', 'logPaintedBeacon',
|
|
54
54
|
'logRenewOutcome', 'makeTestActionContext', 'mintBearerFromAppKey',
|
|
55
|
-
'mintBearerFromSessionToken', 'nextRunAfter', 'normalizeResourcePage', 'notFound',
|
|
55
|
+
'mintBearerFromSessionToken', 'nextRunAfter', 'normalizeResourcePage', 'notFound', 'notifyInternalUsers',
|
|
56
56
|
'parseExchangeRejection', 'parseUuid', 'principalKind', 'readCookie', 'recordNetworkCapture',
|
|
57
57
|
'redactUrl', 'replaceChildRows', 'requireAuth', 'requireCompanyScope', 'resolveFont',
|
|
58
58
|
'resolveResource', 'sendThrottled', 'serializeBearerCookie', 'serializeCookieProbe',
|
package/dist/notifications.d.ts
CHANGED
|
@@ -44,4 +44,67 @@ export declare function clearClientNotifications({ assembly, clientId, ctaParams
|
|
|
44
44
|
cleared: number;
|
|
45
45
|
failed: number;
|
|
46
46
|
}>;
|
|
47
|
+
interface InternalUserRecord {
|
|
48
|
+
id: string;
|
|
49
|
+
}
|
|
50
|
+
interface InstallerNotificationsClient {
|
|
51
|
+
listInternalUsers(args: {
|
|
52
|
+
nextToken?: string;
|
|
53
|
+
limit?: number;
|
|
54
|
+
}): Promise<{
|
|
55
|
+
data?: InternalUserRecord[] | null;
|
|
56
|
+
nextToken?: string;
|
|
57
|
+
}>;
|
|
58
|
+
createNotification(args: {
|
|
59
|
+
requestBody: {
|
|
60
|
+
senderId: string;
|
|
61
|
+
senderCompanyId?: string;
|
|
62
|
+
recipientInternalUserId: string;
|
|
63
|
+
notificationSettingId?: string;
|
|
64
|
+
deliveryTargets: {
|
|
65
|
+
inProduct: {
|
|
66
|
+
title: string;
|
|
67
|
+
body: string;
|
|
68
|
+
ctaParams: Record<string, string>;
|
|
69
|
+
};
|
|
70
|
+
email?: Record<string, unknown>;
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
}): Promise<unknown>;
|
|
74
|
+
}
|
|
75
|
+
interface NotifyInternalUsersDeps {
|
|
76
|
+
getAssembly: () => Promise<unknown>;
|
|
77
|
+
getInstallerAssembly: () => Promise<InstallerNotificationsClient>;
|
|
78
|
+
getIdentity: () => Promise<{
|
|
79
|
+
clientId?: string;
|
|
80
|
+
internalUserId?: string;
|
|
81
|
+
companyId?: string;
|
|
82
|
+
} | null>;
|
|
83
|
+
}
|
|
84
|
+
export interface NotifyInternalUsersInput {
|
|
85
|
+
title: string;
|
|
86
|
+
body: string;
|
|
87
|
+
/** Becomes the notification's inProduct ctaParams (the `/n` surface renders from these). */
|
|
88
|
+
ctaParams: Record<string, string>;
|
|
89
|
+
/** Email delivery target (subject, header, htmlBody, title); omit for in-product only. */
|
|
90
|
+
email?: Record<string, unknown>;
|
|
91
|
+
/** Per-user toggleable category declared in the manifest `notificationSettings`. */
|
|
92
|
+
notificationSettingId?: string;
|
|
93
|
+
/** Notify only these internal users; omit to notify every internal user. Limited-access staff are not filtered out: pass recipientIds when the audience must respect Client Access. */
|
|
94
|
+
recipientIds?: string[];
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Notifies internal users on behalf of the current viewer — the only way a
|
|
98
|
+
* client-triggered event can reach staff, since a client's own getAssembly()
|
|
99
|
+
* client can neither create notifications for them nor list them. The send and
|
|
100
|
+
* the staff list use the installer client; the viewer's client is never touched.
|
|
101
|
+
*
|
|
102
|
+
* Throws only when there is no verified viewer (an auth gate, not a send
|
|
103
|
+
* failure). List and send failures never throw: the caller's data write already
|
|
104
|
+
* committed, so they are logged and returned as counts.
|
|
105
|
+
*/
|
|
106
|
+
export declare function notifyInternalUsers({ title, body, ctaParams, email, notificationSettingId, recipientIds }: NotifyInternalUsersInput, deps?: NotifyInternalUsersDeps): Promise<{
|
|
107
|
+
sent: number;
|
|
108
|
+
failed: number;
|
|
109
|
+
}>;
|
|
47
110
|
export {};
|
package/dist/notifications.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
// (lib/notify.ts): a 429-aware fan-out throttle and the client badge-clear
|
|
3
3
|
// pattern. They live in the package so the behavior is a deterministic import,
|
|
4
4
|
// not prose the build agent re-implements (and sometimes gets wrong) each time.
|
|
5
|
+
import { getAssembly, getInstallerAssembly, getSessionIdentity } from './assembly.js';
|
|
6
|
+
import { getInstallerAssemblyForCaller } from './installer-caller.js';
|
|
7
|
+
import { listAll } from './pagination.js';
|
|
5
8
|
// Platform writes are capped at 5 concurrent per token; 3 leaves headroom for
|
|
6
9
|
// the app's own unrelated in-flight writes.
|
|
7
10
|
const CONCURRENCY = 3;
|
|
@@ -81,3 +84,57 @@ export async function clearClientNotifications({ assembly, clientId, ctaParams,
|
|
|
81
84
|
return { cleared: 0, failed: 0 };
|
|
82
85
|
}
|
|
83
86
|
}
|
|
87
|
+
const defaultNotifyDeps = {
|
|
88
|
+
getAssembly,
|
|
89
|
+
getInstallerAssembly: getInstallerAssembly,
|
|
90
|
+
getIdentity: getSessionIdentity,
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Notifies internal users on behalf of the current viewer — the only way a
|
|
94
|
+
* client-triggered event can reach staff, since a client's own getAssembly()
|
|
95
|
+
* client can neither create notifications for them nor list them. The send and
|
|
96
|
+
* the staff list use the installer client; the viewer's client is never touched.
|
|
97
|
+
*
|
|
98
|
+
* Throws only when there is no verified viewer (an auth gate, not a send
|
|
99
|
+
* failure). List and send failures never throw: the caller's data write already
|
|
100
|
+
* committed, so they are logged and returned as counts.
|
|
101
|
+
*/
|
|
102
|
+
export async function notifyInternalUsers({ title, body, ctaParams, email, notificationSettingId, recipientIds }, deps = defaultNotifyDeps) {
|
|
103
|
+
// The auth gate: a missing viewer must surface, unlike the failures below.
|
|
104
|
+
await deps.getAssembly();
|
|
105
|
+
let installer;
|
|
106
|
+
try {
|
|
107
|
+
installer = (await getInstallerAssemblyForCaller(deps));
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
console.error('notifyInternalUsers could not get the installer client', err);
|
|
111
|
+
return { sent: 0, failed: 0 };
|
|
112
|
+
}
|
|
113
|
+
const identity = await deps.getIdentity();
|
|
114
|
+
const senderId = identity?.clientId ?? identity?.internalUserId;
|
|
115
|
+
if (!senderId)
|
|
116
|
+
throw new Error('notifyInternalUsers requires a verified viewer');
|
|
117
|
+
let recipients;
|
|
118
|
+
try {
|
|
119
|
+
recipients =
|
|
120
|
+
recipientIds ??
|
|
121
|
+
(await listAll((nextToken) => installer.listInternalUsers({ nextToken, limit: 100 }))).map((u) => u.id);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
console.error('notifyInternalUsers could not list internal users', err);
|
|
125
|
+
return { sent: 0, failed: 0 };
|
|
126
|
+
}
|
|
127
|
+
const results = await sendThrottled(recipients, (recipientInternalUserId) => installer.createNotification({
|
|
128
|
+
requestBody: {
|
|
129
|
+
senderId,
|
|
130
|
+
// A multi-company client must name the company it acts under, or the platform rejects the send.
|
|
131
|
+
senderCompanyId: identity?.companyId,
|
|
132
|
+
recipientInternalUserId,
|
|
133
|
+
notificationSettingId,
|
|
134
|
+
deliveryTargets: { inProduct: { title, body, ctaParams }, ...(email && { email }) },
|
|
135
|
+
},
|
|
136
|
+
}));
|
|
137
|
+
logFailures(results, 'internal user notifications');
|
|
138
|
+
const failed = results.filter((r) => r.status === 'rejected').length;
|
|
139
|
+
return { sent: results.length - failed, failed };
|
|
140
|
+
}
|
package/package.json
CHANGED
|
@@ -95,6 +95,8 @@ Detect the notification surface by the route itself — code under `app/n/` IS t
|
|
|
95
95
|
|
|
96
96
|
## Creating a notification
|
|
97
97
|
|
|
98
|
+
This is the **staff-triggered** pattern: the viewer's own client sends. When a client triggers a notification to staff, use `notifyInternalUsers` instead (see "Client-triggered sends to staff").
|
|
99
|
+
|
|
98
100
|
```ts
|
|
99
101
|
const assembly = await getAssembly();
|
|
100
102
|
const id = await getSessionIdentity();
|
|
@@ -159,6 +161,24 @@ export async function GET() {
|
|
|
159
161
|
|
|
160
162
|
Send a fragment, not a document, and don't attempt layout or branding: every tag outside that list — `div`, `table`, headings, `img`, `class`, inline styles, `script` — is stripped server-side with no error, leaving its text run together unformatted, and the platform's email template supplies the frame. The plain-text alternative is derived from `htmlBody` automatically; pass `body` alongside it only to write that text yourself.
|
|
161
163
|
|
|
164
|
+
### Client-triggered sends to staff
|
|
165
|
+
|
|
166
|
+
A client's own `getAssembly()` client **cannot create notifications or list internal users** — those calls fail for a client viewer. Every send to staff that a client triggers (a submission, a decision, a message) goes through `notifyInternalUsers`, which checks the viewer is verified, then lists staff and sends with the installer client, using the viewer's `clientId` and `companyId` as sender:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import { notifyInternalUsers } from '@assembly-js/app-foundation/server';
|
|
170
|
+
|
|
171
|
+
// After the client's write committed. Never throws on send failures; returns counts.
|
|
172
|
+
const { sent, failed } = await notifyInternalUsers({
|
|
173
|
+
title: 'New submission received',
|
|
174
|
+
body: 'Jane Doe submitted their brand questionnaire.',
|
|
175
|
+
ctaParams: { submissionId: submission.id },
|
|
176
|
+
// email, notificationSettingId and recipientIds (default: every internal user) are optional
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Keep `getAssembly()` for notifications a **staff member** triggers — that viewer's client can send them directly.
|
|
181
|
+
|
|
162
182
|
### Company scoping, by recipient type
|
|
163
183
|
|
|
164
184
|
Every notification is scoped to a company, and which field supplies it depends on **who the recipient is** — the part that trips up multi-company clients:
|
|
@@ -169,22 +189,19 @@ Every notification is scoped to a company, and which field supplies it depends o
|
|
|
169
189
|
The field is only *required* when the relevant party belongs to more than one company (single-company members are auto-inferred), but passing the company id when you have it (e.g. `id.companyId` from `getSessionIdentity()`) is always safe.
|
|
170
190
|
|
|
171
191
|
```ts
|
|
172
|
-
// Multi-company client (sender) notifying internal staff:
|
|
173
|
-
// recipientCompanyId does NOT apply (recipient is internal).
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
recipientInternalUserId: assignedStaffId,
|
|
180
|
-
deliveryTargets: { inProduct: { title: 'Client approved the proposal', ctaParams: { proposalId: proposal.id } } },
|
|
181
|
-
},
|
|
192
|
+
// Multi-company client (sender) notifying internal staff: notifyInternalUsers sets senderId and
|
|
193
|
+
// senderCompanyId from the viewer; recipientCompanyId does NOT apply (recipient is internal).
|
|
194
|
+
await notifyInternalUsers({
|
|
195
|
+
title: 'Client approved the proposal',
|
|
196
|
+
ctaParams: { proposalId: proposal.id },
|
|
197
|
+
body: 'The client approved the proposal.',
|
|
198
|
+
recipientIds: [assignedStaffId],
|
|
182
199
|
});
|
|
183
200
|
```
|
|
184
201
|
|
|
185
202
|
To notify multiple recipients (e.g., several internal users), make one `createNotification` call per recipient. See "Apps with multiple notification types" below.
|
|
186
203
|
|
|
187
|
-
**`listInternalUsers` / `listClients` are paginated** — one call returns only the first page. To notify *everyone
|
|
204
|
+
**`listInternalUsers` / `listClients` are paginated** — one call returns only the first page. To notify *everyone* from a staff-triggered event, collect all recipients before fanning out, or you'll silently skip later-page recipients (a client-triggered event uses `notifyInternalUsers`, which walks every page itself). `listAll` from `@assembly-js/app-foundation/client` does the walk — import it, don't rewrite it:
|
|
188
205
|
|
|
189
206
|
```ts
|
|
190
207
|
import { listAll } from '@assembly-js/app-foundation/client';
|
|
@@ -365,17 +382,15 @@ Either way, the clear is one foundation call — `clearClientNotifications({ ass
|
|
|
365
382
|
|
|
366
383
|
Each notification type is its own `createNotification` call with its own `ctaParams` shape.
|
|
367
384
|
|
|
368
|
-
For an event that needs multiple recipients (e.g., a submission notifies the assigned staff member and the requesting client),
|
|
385
|
+
For an event that needs multiple recipients (e.g., a submission notifies the assigned staff member and the requesting client), send one notification per recipient; when a client triggers it, the staff one goes through `notifyInternalUsers` (the viewer client cannot create it). Each call can have different `ctaParams` — internal users typically want a filter scoped to the client; clients want a deep link to their own record. A fixed pair like this stays under the 5-concurrent cap, so a plain `Promise.all` is fine; reach for `sendThrottled` only when the count is driven by a list.
|
|
369
386
|
|
|
370
387
|
```ts
|
|
371
388
|
await Promise.all([
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
} },
|
|
378
|
-
} }),
|
|
389
|
+
notifyInternalUsers({
|
|
390
|
+
title: 'New submission', body: `${clientName} submitted ${formName}.`,
|
|
391
|
+
ctaParams: { submissionId: submission.id, view: 'review' },
|
|
392
|
+
recipientIds: [assignedStaffId],
|
|
393
|
+
}),
|
|
379
394
|
assembly.createNotification({ requestBody: {
|
|
380
395
|
senderId: clientId, recipientClientId: clientId,
|
|
381
396
|
deliveryTargets: { inProduct: {
|
|
@@ -388,15 +403,17 @@ await Promise.all([
|
|
|
388
403
|
|
|
389
404
|
### Fanning out to internal users with Client Access
|
|
390
405
|
|
|
391
|
-
For a client- or company-scoped event whose audience is "all internal users with access," filter yourself — notifying a user about a client they can't access violates the permission model and may leak client info. Users with `isClientAccessLimited: false` see everyone; those with `true` see only clients whose company is in their `companyAccessList`. Every client has a company (real or placeholder), so the company check covers all clients.
|
|
406
|
+
For a client- or company-scoped event whose audience is "all internal users with access," filter yourself, then pass the result to `notifyInternalUsers` as `recipientIds` — notifying a user about a client they can't access violates the permission model and may leak client info. Users with `isClientAccessLimited: false` see everyone; those with `true` see only clients whose company is in their `companyAccessList`. Every client has a company (real or placeholder), so the company check covers all clients.
|
|
392
407
|
|
|
393
408
|
```ts
|
|
394
409
|
const { companyIds = [] } = await assembly.retrieveClient({ id: clientId });
|
|
395
410
|
|
|
411
|
+
// A client viewer cannot list staff, so list with the installer client.
|
|
412
|
+
const installer = await getInstallerAssembly();
|
|
396
413
|
const internalUsers = [];
|
|
397
414
|
let nextToken: string | undefined;
|
|
398
415
|
do {
|
|
399
|
-
const page = await
|
|
416
|
+
const page = await installer.listInternalUsers({ nextToken });
|
|
400
417
|
internalUsers.push(...(page.data ?? []));
|
|
401
418
|
nextToken = page.nextToken ?? undefined;
|
|
402
419
|
} while (nextToken);
|
|
@@ -409,16 +426,12 @@ const recipients = internalUsers.filter(
|
|
|
409
426
|
);
|
|
410
427
|
// Add `.filter((u) => u.role === 'admin')` when the plan is admins/staff only.
|
|
411
428
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
},
|
|
419
|
-
}),
|
|
420
|
-
);
|
|
421
|
-
logFailures(results, 'notifications');
|
|
429
|
+
await notifyInternalUsers({
|
|
430
|
+
title: 'New submission',
|
|
431
|
+
body: 'A client submitted a new request.',
|
|
432
|
+
ctaParams: { submissionId: submission.id },
|
|
433
|
+
recipientIds: recipients.map((u) => u.id),
|
|
434
|
+
});
|
|
422
435
|
```
|
|
423
436
|
|
|
424
437
|
A company-scoped event uses `companyId` directly — same filter, same shape. Workspace-wide notifications (tied to no client or company) go to all internal users, no filter.
|