@assembly-js/app-foundation 0.0.9 → 0.0.10
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 +2 -2
- package/bin/check-token-usage.mjs +29 -3
- package/dist/assembly.d.ts +28 -0
- package/dist/assembly.js +57 -9
- package/dist/notifications.d.ts +47 -0
- package/dist/notifications.js +83 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/package.json +1 -1
- package/skills/assembly-notifications/SKILL.md +6 -50
- package/skills/clearing-client-notifications/SKILL.md +48 -51
package/CLAUDE.md
CHANGED
|
@@ -919,7 +919,7 @@ try {
|
|
|
919
919
|
|
|
920
920
|
Writes (creates, updates, deletes — including `createNotification` and `markNotificationRead`) are capped at **5 concurrent per token**. `Promise.all(list.map(...))` over a recipient or record list fires every call at once; past ~5 items the excess come back `429` and either get dropped or surface to the user as a failed request. It passes a quick test and breaks once a real workspace has enough rows — so don't wait to see it fail.
|
|
921
921
|
|
|
922
|
-
|
|
922
|
+
Route every list-sized write fan-out through `sendThrottled(items, fn)` from `@assembly-js/app-foundation/server` — concurrency 3, exponential-backoff retry on `429`, settles instead of throwing. Do not write your own copy. Notification sends and badge clears included (for badge clears, prefer `clearClientNotifications`, which throttles internally). A **fixed handful** of calls (two or three for one event) is fine with `Promise.all` — the cap only bites when the count is driven by a list.
|
|
923
923
|
|
|
924
924
|
### Custom fields — ALWAYS discover the key dynamically, never hardcode
|
|
925
925
|
|
|
@@ -1112,7 +1112,7 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
|
|
|
1112
1112
|
- ❌ Don't detect viewer type with `try { listClients() } catch (403)`. Use `getSessionIdentity()` (server) or `useAssembly()` (client) and branch on `internalUserId` vs `clientId`.
|
|
1113
1113
|
- ❌ Don't include `companyId` (or any other unchanged field) in `updateClient.requestBody` "for completeness". Each field in `requestBody` is treated as a write — passing `companyId` triggers a company-reassignment path that clients can't perform, and the whole update fails. Pass ONLY the fields the user is actually changing.
|
|
1114
1114
|
- ❌ Don't hardcode mutation error messages like "Failed to save preference". Read `error` from the response body and surface it to the user.
|
|
1115
|
-
- ❌ Don't `Promise.all` a list-sized write fan-out (one `createNotification` / `markNotificationRead` / update per recipient or record). Writes cap at 5 concurrent per token; firing a whole list 429s and silently drops the overflow.
|
|
1115
|
+
- ❌ Don't `Promise.all` a list-sized write fan-out (one `createNotification` / `markNotificationRead` / update per recipient or record). Writes cap at 5 concurrent per token; firing a whole list 429s and silently drops the overflow. Use `sendThrottled` from `@assembly-js/app-foundation/server`. A fixed handful of calls is the only exception. See "Write rate limit" above.
|
|
1116
1116
|
- ❌ Don't use title case — see "Labels, copy, and dropdowns" above.
|
|
1117
1117
|
- ❌ Don't render raw IDs or pass a raw ID string as `value` on `ComboboxItem` — see "Select and Combobox — base-ui differences" above.
|
|
1118
1118
|
- ❌ Don't write a scalar string to a custom field that has options. **All option-bearing custom fields are `multiSelect` in Assembly's schema** — even ones that LOOK like single-select dropdowns. The value must always be an array of option keys: `{ favorite_pizza: ['pepperoni'] }`, never `{ favorite_pizza: 'pepperoni' }`. A scalar string for a multiSelect causes a 400 with `invalid custom field option <value> provided`.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Fails the build if any page reads the session token from `searchParams
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Fails the build if any page reads the session token from `searchParams` —
|
|
3
|
+
// except in a legacy (pre-bearer) app, where it's the only identity path and
|
|
4
|
+
// this is a warning instead. Dependency-free on purpose: it runs inside the
|
|
5
|
+
// generated app's sandbox before any deps beyond Node exist.
|
|
5
6
|
//
|
|
6
7
|
// Auth is cookie-borne in the bearer model: the foundation middleware exchanges
|
|
7
8
|
// the first-load `?token=` for an HttpOnly bearer cookie exactly once, and
|
|
@@ -10,12 +11,25 @@
|
|
|
10
11
|
// renders its auth-rejection state to everyone after any client-side navigation
|
|
11
12
|
// (`router.push`, `<Link>`), which drops query params. Fetch per-user data via
|
|
12
13
|
// server actions (useServerAction) or authFetch route handlers instead.
|
|
14
|
+
//
|
|
15
|
+
// A legacy app (cloned before the bearer model existed) has no bearer cookie to
|
|
16
|
+
// exchange into — URL-token auth is its only identity path, so the same pattern
|
|
17
|
+
// is expected there, not a bug. Severity is decided by probing two marker files
|
|
18
|
+
// from the checkout root, mirroring ProbeFoundationAuthVersionFromGithub in
|
|
19
|
+
// logic_build.go (mintBearerProbePath / packageWorldProbePath): mint-bearer.ts
|
|
20
|
+
// present -> bearer-capable, fatal; absent + assembly.ts present -> legacy, warn
|
|
21
|
+
// and exit 0; neither present -> package-world app (bearer-capable via
|
|
22
|
+
// @assembly-js/app-foundation in node_modules), fatal as today.
|
|
13
23
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
14
24
|
import { join, relative, sep } from 'node:path';
|
|
15
25
|
|
|
16
26
|
const ROOT = process.cwd();
|
|
17
27
|
const APP_DIR = join(ROOT, 'app');
|
|
18
28
|
|
|
29
|
+
// Mirrors mintBearerProbePath / packageWorldProbePath in logic_build.go.
|
|
30
|
+
const MINT_BEARER_PROBE_PATH = 'lib/mint-bearer.ts';
|
|
31
|
+
const PACKAGE_WORLD_PROBE_PATH = 'lib/assembly.ts';
|
|
32
|
+
|
|
19
33
|
function pageFiles(dir) {
|
|
20
34
|
const out = [];
|
|
21
35
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -114,11 +128,23 @@ for (const file of pageFiles(APP_DIR)) {
|
|
|
114
128
|
}
|
|
115
129
|
|
|
116
130
|
if (violations.length > 0) {
|
|
131
|
+
const isLegacy =
|
|
132
|
+
!existsSync(join(ROOT, MINT_BEARER_PROBE_PATH)) && existsSync(join(ROOT, PACKAGE_WORLD_PROBE_PATH));
|
|
133
|
+
|
|
117
134
|
console.error('');
|
|
118
135
|
console.error('A page reads the session token from searchParams:');
|
|
119
136
|
console.error('');
|
|
120
137
|
for (const v of violations) console.error(' - ' + v);
|
|
121
138
|
console.error('');
|
|
139
|
+
|
|
140
|
+
if (isLegacy) {
|
|
141
|
+
console.error('This is a pre-bearer app, so this is a warning, not a build failure:');
|
|
142
|
+
console.error('URL-token auth is its only identity path. Because client-side navigation');
|
|
143
|
+
console.error('(router.push, <Link>) drops query params, any page reading');
|
|
144
|
+
console.error('searchParams.token must thread the token through its outgoing hrefs.');
|
|
145
|
+
process.exit(0);
|
|
146
|
+
}
|
|
147
|
+
|
|
122
148
|
console.error('Auth is in the bearer cookie, not the URL — no page, not even a surface');
|
|
123
149
|
console.error('entry page, should read searchParams.token. getAssembly() and');
|
|
124
150
|
console.error('getSessionIdentity() read the bearer cookie; fetch per-user data via');
|
package/dist/assembly.d.ts
CHANGED
|
@@ -20,6 +20,34 @@ export declare class TokenInvalidError extends Error {
|
|
|
20
20
|
constructor(message?: string);
|
|
21
21
|
}
|
|
22
22
|
export declare function getAssembly(input?: AssemblyInput): Promise<AssemblyAPI>;
|
|
23
|
+
/**
|
|
24
|
+
* Runs `task` after the response is sent (Next's `after()`), with a viewer-
|
|
25
|
+
* authenticated SDK client — the ONLY supported way to defer authenticated work
|
|
26
|
+
* past the response boundary. Use it for side work that shouldn't block the
|
|
27
|
+
* render, e.g. clearing a client's notification badge on view.
|
|
28
|
+
*
|
|
29
|
+
* Why this exists: inside a bare `after()` callback the response has already
|
|
30
|
+
* gone out and `cookies()`/`headers()` are no longer reliably readable, so
|
|
31
|
+
* `getAssembly()` called there throws `TokenInvalidError` on every request —
|
|
32
|
+
* silently, since nothing surfaces to the viewer. This helper captures the
|
|
33
|
+
* viewer's credential at call time (while request scope is still live) and
|
|
34
|
+
* hands the deferred task a ready client built from it.
|
|
35
|
+
*
|
|
36
|
+
* Failures log and return — the viewer's response already succeeded, so a
|
|
37
|
+
* failed deferred task must never become an unhandled rejection.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* // app/c/requests/[id]/page.tsx — clear the badge without blocking render
|
|
41
|
+
* afterAssembly(async (assembly) => {
|
|
42
|
+
* await clearClientNotifications({ assembly, clientId, ctaParams: { requestId } });
|
|
43
|
+
* });
|
|
44
|
+
*
|
|
45
|
+
* Legacy (exchange-off) apps have no ambient bearer; pass the page's session
|
|
46
|
+
* token explicitly: `afterAssembly(task, { token })`.
|
|
47
|
+
*/
|
|
48
|
+
export declare function afterAssembly(task: (assembly: AssemblyAPI) => Promise<unknown>, options?: {
|
|
49
|
+
token?: string;
|
|
50
|
+
}): void;
|
|
23
51
|
/**
|
|
24
52
|
* OFFLINE / BACKGROUND ONLY — cron jobs, webhook handlers, and any other code
|
|
25
53
|
* path where there is NO user in the loop and thus no session token or bearer
|
package/dist/assembly.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { assemblyApi } from '@assembly-js/node-sdk';
|
|
2
2
|
import { cookies, headers } from 'next/headers';
|
|
3
|
-
import { NextResponse } from 'next/server';
|
|
3
|
+
import { after, NextResponse } from 'next/server';
|
|
4
4
|
import { BEARER_COOKIE_NAME, BEARER_HEADER_NAME, decodeBearer, isSessionExchangeEnabled, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
|
|
5
5
|
import { mintBearerFromAppKey } from './mint-bearer.js';
|
|
6
6
|
// Trusted API origin for this stack, supplied by the build environment — NOT by
|
|
@@ -94,15 +94,18 @@ export class TokenInvalidError extends Error {
|
|
|
94
94
|
export async function getAssembly(input) {
|
|
95
95
|
const token = typeof input === 'string' ? input : input?.token;
|
|
96
96
|
// Exchange enabled ⇒ this is a bearer-capable app, and it authenticates via
|
|
97
|
-
// the exchanged bearer
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
97
|
+
// the exchanged bearer. `resolveRequestBearer()` reads it from `cookies()`/
|
|
98
|
+
// `headers()`, which are only reliably readable during the request/render —
|
|
99
|
+
// NOT inside a deferred `next/server` `after()` callback (the response has
|
|
100
|
+
// already been sent by then, and those Dynamic APIs can silently fail to
|
|
101
|
+
// return the proxy-forwarded header or cookie there). Code that must run
|
|
102
|
+
// after the response — e.g. a badge-clear a caller wants off the render's
|
|
103
|
+
// critical path — should resolve the bearer synchronously BEFORE calling
|
|
104
|
+
// after() and pass it through explicitly as `input.token`; this honors that
|
|
105
|
+
// pre-resolved bearer instead of re-deriving it from request scope. The
|
|
106
|
+
// common case (no explicit token) is unchanged: derive from the request.
|
|
104
107
|
if (isSessionExchangeEnabled()) {
|
|
105
|
-
const bearer = await resolveRequestBearer();
|
|
108
|
+
const bearer = token ?? (await resolveRequestBearer());
|
|
106
109
|
if (!bearer) {
|
|
107
110
|
throw new TokenInvalidError('Assembly bearer is required');
|
|
108
111
|
}
|
|
@@ -146,6 +149,51 @@ export async function getAssembly(input) {
|
|
|
146
149
|
}
|
|
147
150
|
return assembly;
|
|
148
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Runs `task` after the response is sent (Next's `after()`), with a viewer-
|
|
154
|
+
* authenticated SDK client — the ONLY supported way to defer authenticated work
|
|
155
|
+
* past the response boundary. Use it for side work that shouldn't block the
|
|
156
|
+
* render, e.g. clearing a client's notification badge on view.
|
|
157
|
+
*
|
|
158
|
+
* Why this exists: inside a bare `after()` callback the response has already
|
|
159
|
+
* gone out and `cookies()`/`headers()` are no longer reliably readable, so
|
|
160
|
+
* `getAssembly()` called there throws `TokenInvalidError` on every request —
|
|
161
|
+
* silently, since nothing surfaces to the viewer. This helper captures the
|
|
162
|
+
* viewer's credential at call time (while request scope is still live) and
|
|
163
|
+
* hands the deferred task a ready client built from it.
|
|
164
|
+
*
|
|
165
|
+
* Failures log and return — the viewer's response already succeeded, so a
|
|
166
|
+
* failed deferred task must never become an unhandled rejection.
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* // app/c/requests/[id]/page.tsx — clear the badge without blocking render
|
|
170
|
+
* afterAssembly(async (assembly) => {
|
|
171
|
+
* await clearClientNotifications({ assembly, clientId, ctaParams: { requestId } });
|
|
172
|
+
* });
|
|
173
|
+
*
|
|
174
|
+
* Legacy (exchange-off) apps have no ambient bearer; pass the page's session
|
|
175
|
+
* token explicitly: `afterAssembly(task, { token })`.
|
|
176
|
+
*/
|
|
177
|
+
export function afterAssembly(task, options) {
|
|
178
|
+
// Capture the credential NOW — after() runs past the response boundary,
|
|
179
|
+
// where cookies()/headers() reads are unreliable.
|
|
180
|
+
const credential = options?.token
|
|
181
|
+
? Promise.resolve(options.token)
|
|
182
|
+
: resolveRequestBearer().catch(() => undefined);
|
|
183
|
+
after(async () => {
|
|
184
|
+
try {
|
|
185
|
+
const token = await credential;
|
|
186
|
+
if (!token) {
|
|
187
|
+
console.error('afterAssembly: no viewer credential was available at call time; deferred task skipped');
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
await task(await getAssembly({ token }));
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
console.error('afterAssembly: deferred task failed', err);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
149
197
|
// Duplicated from lib/auth.ts to avoid a circular import.
|
|
150
198
|
function tokenExpiredResponse() {
|
|
151
199
|
return NextResponse.json({ error: 'Unauthorized', code: 'token_expired' }, { status: 401, headers: { [TOKEN_EXPIRED_HEADER]: '1' } });
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export declare function sendThrottled<T, R>(items: T[], task: (item: T) => Promise<R>): Promise<PromiseSettledResult<R>[]>;
|
|
2
|
+
export declare function logFailures(results: PromiseSettledResult<unknown>[], label: string): void;
|
|
3
|
+
interface NotificationRecord {
|
|
4
|
+
id?: string;
|
|
5
|
+
deliveryTargets?: {
|
|
6
|
+
inProduct?: {
|
|
7
|
+
ctaParams?: Record<string, string>;
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
interface NotificationsClient {
|
|
12
|
+
listNotifications(args: {
|
|
13
|
+
recipientClientId: string;
|
|
14
|
+
}): Promise<{
|
|
15
|
+
data?: NotificationRecord[];
|
|
16
|
+
}>;
|
|
17
|
+
markNotificationRead(args: {
|
|
18
|
+
id: string;
|
|
19
|
+
}): Promise<unknown>;
|
|
20
|
+
}
|
|
21
|
+
export interface ClearClientNotificationsInput {
|
|
22
|
+
/** SDK client from getAssembly() — any client works, only two methods are used. */
|
|
23
|
+
assembly: NotificationsClient;
|
|
24
|
+
/** The viewer's clientId (e.g. from getSessionIdentity()). No-op when absent,
|
|
25
|
+
* so callers can pass it straight through without narrowing first. */
|
|
26
|
+
clientId: string | undefined | null;
|
|
27
|
+
/** Clear only notifications whose inProduct ctaParams contain ALL of these
|
|
28
|
+
* key/value pairs — the same ctaParams the app set on create. Omit to clear
|
|
29
|
+
* every unread notification for the client (single-notification-kind apps). */
|
|
30
|
+
ctaParams?: Record<string, string>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Clears the client portal badge: lists the client's unread in-product
|
|
34
|
+
* notifications, filters in memory by the given ctaParams (the API has no
|
|
35
|
+
* sub-recipient filter), and marks the matches read through the throttled
|
|
36
|
+
* fan-out so long lists never 429. Every createNotification({ recipientClientId })
|
|
37
|
+
* an app makes must be paired with a call to this — on view for awareness
|
|
38
|
+
* notifications, inside the completing server action for prompts.
|
|
39
|
+
*
|
|
40
|
+
* Never throws: the viewer's page or action already succeeded, so a failed
|
|
41
|
+
* clear logs and returns rather than breaking the response.
|
|
42
|
+
*/
|
|
43
|
+
export declare function clearClientNotifications({ assembly, clientId, ctaParams, }: ClearClientNotificationsInput): Promise<{
|
|
44
|
+
cleared: number;
|
|
45
|
+
failed: number;
|
|
46
|
+
}>;
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Notification helpers generated apps previously had to hand-write per app
|
|
2
|
+
// (lib/notify.ts): a 429-aware fan-out throttle and the client badge-clear
|
|
3
|
+
// pattern. They live in the package so the behavior is a deterministic import,
|
|
4
|
+
// not prose the build agent re-implements (and sometimes gets wrong) each time.
|
|
5
|
+
// Platform writes are capped at 5 concurrent per token; 3 leaves headroom for
|
|
6
|
+
// the app's own unrelated in-flight writes.
|
|
7
|
+
const CONCURRENCY = 3;
|
|
8
|
+
const MAX_RETRIES = 3;
|
|
9
|
+
async function withRetry(task) {
|
|
10
|
+
for (let attempt = 0;; attempt++) {
|
|
11
|
+
try {
|
|
12
|
+
return await task();
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
const status = err?.status; // SDK throws ApiError with numeric status
|
|
16
|
+
if (status !== 429 || attempt >= MAX_RETRIES)
|
|
17
|
+
throw err;
|
|
18
|
+
await new Promise((r) => setTimeout(r, 200 * 2 ** attempt + Math.random() * 100));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// Runs `task` over every item, at most CONCURRENCY in flight, retrying 429s.
|
|
23
|
+
// Settles instead of throwing so one bad item never aborts the rest.
|
|
24
|
+
export async function sendThrottled(items, task) {
|
|
25
|
+
const results = [];
|
|
26
|
+
let cursor = 0;
|
|
27
|
+
const worker = async () => {
|
|
28
|
+
while (cursor < items.length) {
|
|
29
|
+
const i = cursor++;
|
|
30
|
+
try {
|
|
31
|
+
results[i] = { status: 'fulfilled', value: await withRetry(() => task(items[i])) };
|
|
32
|
+
}
|
|
33
|
+
catch (reason) {
|
|
34
|
+
results[i] = { status: 'rejected', reason };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
|
39
|
+
return results;
|
|
40
|
+
}
|
|
41
|
+
// Log partial failures; the caller's data write already committed, so don't throw.
|
|
42
|
+
export function logFailures(results, label) {
|
|
43
|
+
const failed = results.filter((r) => r.status === 'rejected').length;
|
|
44
|
+
if (failed)
|
|
45
|
+
console.error(`${failed}/${results.length} ${label} failed`);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Clears the client portal badge: lists the client's unread in-product
|
|
49
|
+
* notifications, filters in memory by the given ctaParams (the API has no
|
|
50
|
+
* sub-recipient filter), and marks the matches read through the throttled
|
|
51
|
+
* fan-out so long lists never 429. Every createNotification({ recipientClientId })
|
|
52
|
+
* an app makes must be paired with a call to this — on view for awareness
|
|
53
|
+
* notifications, inside the completing server action for prompts.
|
|
54
|
+
*
|
|
55
|
+
* Never throws: the viewer's page or action already succeeded, so a failed
|
|
56
|
+
* clear logs and returns rather than breaking the response.
|
|
57
|
+
*/
|
|
58
|
+
export async function clearClientNotifications({ assembly, clientId, ctaParams, }) {
|
|
59
|
+
if (!clientId)
|
|
60
|
+
return { cleared: 0, failed: 0 };
|
|
61
|
+
try {
|
|
62
|
+
const { data: notifications = [] } = await assembly.listNotifications({
|
|
63
|
+
recipientClientId: clientId,
|
|
64
|
+
});
|
|
65
|
+
const toClear = notifications.filter((n) => {
|
|
66
|
+
if (!n.id)
|
|
67
|
+
return false;
|
|
68
|
+
if (!ctaParams)
|
|
69
|
+
return true;
|
|
70
|
+
const params = n.deliveryTargets?.inProduct?.ctaParams;
|
|
71
|
+
return (params !== undefined &&
|
|
72
|
+
Object.entries(ctaParams).every(([k, v]) => params[k] === v));
|
|
73
|
+
});
|
|
74
|
+
const results = await sendThrottled(toClear, (n) => assembly.markNotificationRead({ id: n.id }));
|
|
75
|
+
logFailures(results, 'badge clears');
|
|
76
|
+
const failed = results.filter((r) => r.status === 'rejected').length;
|
|
77
|
+
return { cleared: results.length - failed, failed };
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
console.error('clearClientNotifications failed', err);
|
|
81
|
+
return { cleared: 0, failed: 0 };
|
|
82
|
+
}
|
|
83
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from './mint-bearer.js';
|
|
|
6
6
|
export * from './nango.js';
|
|
7
7
|
export * from './app-storage.js';
|
|
8
8
|
export * from './labels.js';
|
|
9
|
+
export * from './notifications.js';
|
|
9
10
|
export * from './api-errors.js';
|
|
10
11
|
export * from './params.js';
|
|
11
12
|
export * from './client.js';
|
package/dist/server.js
CHANGED
|
@@ -10,6 +10,7 @@ export * from './mint-bearer.js';
|
|
|
10
10
|
export * from './nango.js';
|
|
11
11
|
export * from './app-storage.js';
|
|
12
12
|
export * from './labels.js';
|
|
13
|
+
export * from './notifications.js';
|
|
13
14
|
// Returns NextResponse, so route handlers only — but nothing here reads a
|
|
14
15
|
// secret, which is why a client file may still `import type` from them.
|
|
15
16
|
export * from './api-errors.js';
|
package/package.json
CHANGED
|
@@ -278,63 +278,19 @@ Frequency is not a criterion: a one-time client action is a legitimate fit ("Sig
|
|
|
278
278
|
|
|
279
279
|
`createNotification` is a write, and a token's writes are capped at **5 concurrent** (a token-bucket limit shared across *all* the request's writes). `Promise.all(list.map(createNotification))` fires every call at once: past ~5 recipients the overflow returns `429` and is dropped or surfaces as a failed request. It passes a quick test and breaks once a workspace has enough users — so don't wait to see it fail.
|
|
280
280
|
|
|
281
|
-
|
|
281
|
+
The foundation ships the helper — do not write your own copy:
|
|
282
282
|
|
|
283
283
|
```ts
|
|
284
|
-
|
|
285
|
-
const CONCURRENCY = 3;
|
|
286
|
-
const MAX_RETRIES = 4;
|
|
287
|
-
|
|
288
|
-
async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
|
|
289
|
-
for (let attempt = 0; ; attempt++) {
|
|
290
|
-
try {
|
|
291
|
-
return await fn();
|
|
292
|
-
} catch (err) {
|
|
293
|
-
const status = (err as { status?: number })?.status; // SDK throws ApiError with numeric status
|
|
294
|
-
if (status !== 429 || attempt >= MAX_RETRIES) throw err;
|
|
295
|
-
await new Promise((r) => setTimeout(r, 200 * 2 ** attempt + Math.random() * 100));
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Runs `task` over every item, at most CONCURRENCY in flight, retrying 429s.
|
|
301
|
-
// Settles instead of throwing so one bad item never aborts the rest.
|
|
302
|
-
export async function sendThrottled<T, R>(
|
|
303
|
-
items: T[],
|
|
304
|
-
task: (item: T) => Promise<R>,
|
|
305
|
-
): Promise<PromiseSettledResult<R>[]> {
|
|
306
|
-
const results: PromiseSettledResult<R>[] = [];
|
|
307
|
-
let cursor = 0;
|
|
308
|
-
const worker = async () => {
|
|
309
|
-
while (cursor < items.length) {
|
|
310
|
-
const i = cursor++;
|
|
311
|
-
try {
|
|
312
|
-
results[i] = { status: 'fulfilled', value: await withRetry(() => task(items[i])) };
|
|
313
|
-
} catch (reason) {
|
|
314
|
-
results[i] = { status: 'rejected', reason };
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
};
|
|
318
|
-
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
|
319
|
-
return results;
|
|
320
|
-
}
|
|
284
|
+
import { sendThrottled, logFailures } from '@assembly-js/app-foundation/server';
|
|
321
285
|
|
|
322
|
-
// Log partial failures; the caller's data write already committed, so don't throw.
|
|
323
|
-
export function logFailures(results: PromiseSettledResult<unknown>[], label: string) {
|
|
324
|
-
const failed = results.filter((r) => r.status === 'rejected').length;
|
|
325
|
-
if (failed) console.error(`${failed}/${results.length} ${label} failed`);
|
|
326
|
-
}
|
|
327
|
-
```
|
|
328
|
-
|
|
329
|
-
Route every recipient-list fan-out through it, then log partial failures:
|
|
330
|
-
|
|
331
|
-
```ts
|
|
332
286
|
const results = await sendThrottled(recipients, (u) =>
|
|
333
287
|
assembly.createNotification({ requestBody: { senderId, recipientInternalUserId: u.id, deliveryTargets } }),
|
|
334
288
|
);
|
|
335
289
|
logFailures(results, 'notifications');
|
|
336
290
|
```
|
|
337
291
|
|
|
292
|
+
`sendThrottled` runs at most 3 tasks in flight and retries 429s with backoff; it settles instead of throwing so one bad item never aborts the rest. `logFailures` logs partial failures without throwing (the caller's data write already committed). Route every recipient-list write fan-out through it — sends, badge clears, batch updates.
|
|
293
|
+
|
|
338
294
|
A **fixed handful** of calls (two or three for one event) never exceeds the bucket — a plain `Promise.all` is fine there. The cap only bites when the count is driven by a list. The same helper applies to `markNotificationRead` badge clears (see the `clearing-client-notifications` skill).
|
|
339
295
|
|
|
340
296
|
### Avoiding duplicates
|
|
@@ -376,9 +332,9 @@ Because clients see a badge counter rather than a Notification Center, every `cr
|
|
|
376
332
|
- **Clear on view** — for informational notifications. Mark read when the client opens the corresponding content (a status update, a reply, a ready deliverable).
|
|
377
333
|
- **Clear on action** — for notifications that ask the client to *do* something. Mark read inside the server action that completes the work (filling a form, marking a task done, granting an approval) — clearing on view would drop the prompt before they act.
|
|
378
334
|
|
|
379
|
-
Either way,
|
|
335
|
+
Either way, the clear is one foundation call — `clearClientNotifications({ assembly, clientId, ctaParams })` from `@assembly-js/app-foundation/server` — which lists, filters by your `ctaParams`, and marks read with throttling built in.
|
|
380
336
|
|
|
381
|
-
**If your app sends any client notification, load the `clearing-client-notifications` skill** for
|
|
337
|
+
**If your app sends any client notification, load the `clearing-client-notifications` skill** for where to place the call in both patterns and the pre-ship checklist. Don't consider a client-facing app done until the matching clear is implemented and you can name where it runs.
|
|
382
338
|
|
|
383
339
|
## Apps with multiple notification types
|
|
384
340
|
|
|
@@ -1,100 +1,97 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: clearing-client-notifications
|
|
3
|
-
description: REQUIRED whenever the app calls createNotification with recipientClientId. Use when building any client-facing view that shows content a client was notified about, or any client action that completes something a notification prompted — so the portal sidebar badge clears. Covers the two clearing patterns (clear-on-view vs clear-on-action) with
|
|
3
|
+
description: REQUIRED whenever the app calls createNotification with recipientClientId. Use when building any client-facing view that shows content a client was notified about, or any client action that completes something a notification prompted — so the portal sidebar badge clears. Covers the two clearing patterns (clear-on-view vs clear-on-action) with the foundation's clearClientNotifications helper.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
## Why this exists
|
|
7
7
|
|
|
8
|
-
Clients do not have a Notification Center. When you send an in-product notification to a client (`recipientClientId`), the portal shows a **counter badge** next to the app in the sidebar. Nothing clears that badge automatically — the app must
|
|
8
|
+
Clients do not have a Notification Center. When you send an in-product notification to a client (`recipientClientId`), the portal shows a **counter badge** next to the app in the sidebar. Nothing clears that badge automatically — the app must clear it at the right moment. If you create client notifications and never clear them, the badge sticks forever. **This is the single most common bug in client-facing apps**, and it is invisible during a quick demo: the notification arrives correctly, so the create path looks done, but the badge never goes down.
|
|
9
9
|
|
|
10
|
-
Treat every `createNotification({ recipientClientId })` as half of a pair. The other half — the
|
|
10
|
+
Treat every `createNotification({ recipientClientId })` as half of a pair. The other half — the clear — lives in a *different file* (the client's view route or server action), so it's easy to forget. If your app sends any client notification, you are not finished until the matching clear is implemented and you can name where it runs.
|
|
11
11
|
|
|
12
12
|
This does not apply to internal users (their Notification Center auto-clears) or to email-only notifications (no badge). It applies precisely when the recipient is a client and `deliveryTargets.inProduct` is set.
|
|
13
13
|
|
|
14
|
-
##
|
|
15
|
-
|
|
16
|
-
There are two valid clearing patterns. Choosing the wrong one is its own bug:
|
|
14
|
+
## The helper
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
- **Clear on action** — the notification is asking the client to *do* something. Clearing on view is wrong here: the client opens the page, doesn't finish, leaves, and the prompt is gone. Clear it inside the server action that completes the work. Examples: a form to fill out, a task to mark done, an approval to grant, an RSVP to submit.
|
|
16
|
+
Do not hand-roll the clear. The foundation ships it:
|
|
20
17
|
|
|
21
|
-
|
|
18
|
+
```ts
|
|
19
|
+
import { clearClientNotifications } from '@assembly-js/app-foundation/server';
|
|
22
20
|
|
|
23
|
-
|
|
21
|
+
await clearClientNotifications({
|
|
22
|
+
assembly, // from getAssembly()
|
|
23
|
+
clientId: id?.clientId, // from getSessionIdentity(); safe to pass undefined (no-op)
|
|
24
|
+
ctaParams: { submissionId }, // clear only notifications created with these ctaParams
|
|
25
|
+
});
|
|
26
|
+
```
|
|
24
27
|
|
|
25
|
-
|
|
28
|
+
It lists the client's unread notifications, filters in memory by your `ctaParams` (the API has no sub-recipient filter), and marks the matches read through a throttled fan-out that retries 429s — so a long list never trips the 5-concurrent write cap. It never throws: the viewer's page or action already succeeded, so a failed clear logs instead of breaking the response. Omit `ctaParams` only when the app sends a single kind of client notification and the client is landing on the obvious "inbox" view.
|
|
26
29
|
|
|
27
|
-
|
|
30
|
+
## Pick the pattern by intent
|
|
28
31
|
|
|
29
|
-
|
|
32
|
+
There are two valid clearing patterns. Choosing the wrong one is its own bug:
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
- **Clear on view** — the notification is informational ("here's something new to see"). Call `clearClientNotifications` in the server component that renders the corresponding content. Examples: a status update, a reply, a deliverable ready to download, a new resource posted.
|
|
35
|
+
- **Clear on action** — the notification is asking the client to *do* something. Clearing on view is wrong here: the client opens the page, doesn't finish, leaves, and the prompt is gone. Call `clearClientNotifications` inside the `'use server'` action that completes the work, after the work's own write. Examples: a form to fill out, a task to mark done, an approval to grant, an RSVP to submit.
|
|
32
36
|
|
|
33
|
-
|
|
37
|
+
If you clear on view when you should clear on action, you drop reminders the client still needs. If you clear on action when view was right, the badge lingers because the client may never take a discrete action. Match the pattern to what the notification is asking for.
|
|
34
38
|
|
|
35
39
|
```tsx
|
|
36
|
-
// app/(client)/submissions/[id]/page.tsx
|
|
37
|
-
import { getAssembly, getSessionIdentity } from '@assembly-js/app-foundation/server';
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
export default async function SubmissionView({
|
|
41
|
-
params,
|
|
42
|
-
}: {
|
|
43
|
-
params: Promise<{ id: string }>;
|
|
44
|
-
}) {
|
|
40
|
+
// Pattern A — clear on view: app/(client)/submissions/[id]/page.tsx
|
|
41
|
+
import { getAssembly, getSessionIdentity, clearClientNotifications } from '@assembly-js/app-foundation/server';
|
|
42
|
+
|
|
43
|
+
export default async function SubmissionView({ params }: { params: Promise<{ id: string }> }) {
|
|
45
44
|
const assembly = await getAssembly();
|
|
46
45
|
const id = await getSessionIdentity();
|
|
47
46
|
const { id: submissionId } = await params;
|
|
48
47
|
|
|
49
|
-
|
|
50
|
-
const { data: notifications = [] } = await assembly.listNotifications({
|
|
51
|
-
recipientClientId: id?.clientId,
|
|
52
|
-
});
|
|
53
|
-
const toClear = notifications.filter(
|
|
54
|
-
(n) => n.deliveryTargets?.inProduct?.ctaParams?.submissionId === submissionId,
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
const results = await sendThrottled(toClear, (n) => assembly.markNotificationRead({ id: n.id! }));
|
|
58
|
-
logFailures(results, 'badge clears');
|
|
48
|
+
await clearClientNotifications({ assembly, clientId: id?.clientId, ctaParams: { submissionId } });
|
|
59
49
|
|
|
60
50
|
return <SubmissionDetail id={submissionId} />;
|
|
61
51
|
}
|
|
62
52
|
```
|
|
63
53
|
|
|
64
|
-
## Pattern B — clear on action
|
|
65
|
-
|
|
66
54
|
```ts
|
|
67
|
-
// app/(client)/tasks/[id]/complete.ts
|
|
55
|
+
// Pattern B — clear on action: app/(client)/tasks/[id]/complete.ts
|
|
68
56
|
'use server';
|
|
69
|
-
|
|
70
|
-
import { getAssembly, getSessionIdentity } from '@assembly-js/app-foundation/server';
|
|
71
|
-
import { sendThrottled, logFailures } from '@/lib/notify';
|
|
57
|
+
import { getAssembly, getSessionIdentity, clearClientNotifications } from '@assembly-js/app-foundation/server';
|
|
72
58
|
|
|
73
59
|
export async function completeTask(taskId: string) {
|
|
74
60
|
const assembly = await getAssembly();
|
|
75
61
|
const id = await getSessionIdentity();
|
|
76
62
|
|
|
77
|
-
// Complete the requested work first, then clear the notification that prompted it.
|
|
78
63
|
await assembly.updateTask({ id: taskId, requestBody: { status: 'completed' } });
|
|
64
|
+
await clearClientNotifications({ assembly, clientId: id?.clientId, ctaParams: { taskId } });
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Assembly Forms are completed in Assembly's built-in Forms UI. The SDK can assign them with `requestFormResponse` and read submissions with `listFormResponses`, but it does not expose a method for a custom app to submit answers. Never invent a form-submission SDK method. For a form owned by the custom app, persist its data to the app's database before clearing the matching notification.
|
|
79
69
|
|
|
80
|
-
|
|
81
|
-
|
|
70
|
+
## Deferring the clear off the render's critical path
|
|
71
|
+
|
|
72
|
+
Never wrap `getAssembly()` in a bare `after()`: once the response has gone out, `cookies()`/`headers()` are no longer reliably readable, so the deferred `getAssembly()` throws `TokenInvalidError` on every request and the clear silently never runs. If the clear should not block the render, use the foundation's `afterAssembly` — it captures the viewer's credential at call time and hands the deferred task a ready client:
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
import { getSessionIdentity, afterAssembly, clearClientNotifications } from '@assembly-js/app-foundation/server';
|
|
76
|
+
|
|
77
|
+
export default async function SubmissionView({ params }: { params: Promise<{ id: string }> }) {
|
|
78
|
+
const id = await getSessionIdentity();
|
|
79
|
+
const { id: submissionId } = await params;
|
|
80
|
+
|
|
81
|
+
afterAssembly(async (assembly) => {
|
|
82
|
+
await clearClientNotifications({ assembly, clientId: id?.clientId, ctaParams: { submissionId } });
|
|
82
83
|
});
|
|
83
|
-
const toClear = notifications.filter(
|
|
84
|
-
(n) => n.deliveryTargets?.inProduct?.ctaParams?.taskId === taskId,
|
|
85
|
-
);
|
|
86
84
|
|
|
87
|
-
|
|
88
|
-
logFailures(results, 'badge clears');
|
|
85
|
+
return <SubmissionDetail id={submissionId} />;
|
|
89
86
|
}
|
|
90
87
|
```
|
|
91
88
|
|
|
92
|
-
|
|
89
|
+
The synchronous form is the default; reach for `afterAssembly` only when the view genuinely shouldn't wait on the clear.
|
|
93
90
|
|
|
94
91
|
## Checklist before you call a client-facing app done
|
|
95
92
|
|
|
96
|
-
- For every `createNotification({ recipientClientId })`, there is a matching `
|
|
93
|
+
- For every `createNotification({ recipientClientId })`, there is a matching `clearClientNotifications` somewhere in the client flow.
|
|
97
94
|
- The clear runs from the **server** (a server component/route or a `'use server'` action), never from client-side code.
|
|
98
95
|
- The pattern matches the intent — view for awareness, action for prompts.
|
|
99
96
|
- The clear is scoped by `ctaParams` so it only marks the notifications the client actually engaged with.
|
|
100
|
-
-
|
|
97
|
+
- Any deferred clear goes through `afterAssembly`, never a bare `after()` around `getAssembly()`.
|