@assembly-js/app-foundation 0.0.19 → 0.0.21

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 CHANGED
@@ -188,15 +188,14 @@ Because the app runs in an iframe inside the parent dashboard, several normal Ne
188
188
  - **Action buttons go in the header — set via the `useAssemblyHeader` hook.** Submit, Save, Edit, Create — any primary page-level action MUST live in the header (`primaryCta`), not a `<Button>` in the page body. Secondary actions use `secondaryCta`; overflow/tertiary actions use `actionsMenu`. Drive all of it through the `useAssemblyHeader` hook (`app/components/use-assembly-header.ts`) — **never call `AssemblyBridge.header.set*` from your own `useEffect`** (that hand-rolling is what makes header buttons vanish after a re-render). See `.claude/skills/assembly-app-bridge-header/SKILL.md` for the full API.
189
189
  **Exception: the notification surface (`/n`).** The Notification Center embeds the `/n` route in a plain `<iframe>` without app bridge header support. `useAssemblyHeader` calls are silently ignored — header CTAs never appear. On the notification surface, render primary actions as inline `<Button>` elements in the page body. Detect the notification surface by the route — code under `app/n/` IS the surface; for a shared component pass an explicit prop from the `/n` page rather than inferring from `payload.notificationId`, which is best-effort under the session-exchange flow. This exception applies ONLY to the `/n` route; all other surfaces MUST use the header. **Do not reuse a component that calls `useAssemblyHeader` on the `/n` surface** — if it also has an inline action button, both the header CTA and the inline button will render on non-notification surfaces (duplicate CTAs). Either create a separate component for `/n` or accept a prop that switches between header and inline CTAs.
190
190
  - **Theme + fonts.** The portal pushes the workspace's theme to the iframe via `app-bridge`. Don't hardcode brand colors; use neutrals + Tailwind defaults and let the parent's theme show through where appropriate.
191
- - **`allowedDevOrigins` is required in `next.config.ts`.** Next.js 15.3+ blocks cross-origin requests to the dev server by default. Because the app renders in an iframe whose parent origin differs from the dev server hostname (a `*.vercel.run` subdomain), the JS bundle is blocked without this config. Read the sandbox hostname from the `SANDBOX_HOST` env var (set automatically) and add it as a literal string:
191
+ - **`next.config.ts` is platform-owned — never edit it. Your Next config lives in `app.config.ts`.** The wrapper in `next.config.ts` (`withAssemblyDev` from `@assembly-js/app-foundation/next`) spreads `app.config.ts` and applies the iframe-preview invariants on top: `allowedDevOrigins` for the sandbox host (without it the app renders but has zero interactivity — hydration JavaScript is blocked cross-origin) and disabling the React debug channel (without it the preview hangs forever for users on WebSocket-blocking networks). Put your configuration in `app.config.ts`:
192
192
  ```ts
193
- // next.config.ts — the hostname is patched automatically before bun dev
194
- // starts, but if you rewrite next.config.ts, include it:
195
- const nextConfig: NextConfig = {
196
- allowedDevOrigins: ['sb-XXXX.vercel.run'], // use $SANDBOX_HOST value
193
+ // app.config.ts — app-owned; the platform wrapper applies invariants on top
194
+ const appConfig: NextConfig = {
195
+ serverExternalPackages: ["sharp", "canvas", "@napi-rs/canvas"],
197
196
  };
198
197
  ```
199
- Without this, the app appears to render (server HTML loads) but has zero interactivity — buttons, forms, and client components don't work because hydration JavaScript is blocked. **The dev server startup patches this automatically — but if you rewrite `next.config.ts`, preserve the `allowedDevOrigins` entry.**
198
+ `bun run build` fails (`check:next-config`) if `next.config.ts` is rewritten away from the wrapper form.
200
199
  - **Leave `instrumentation-client.ts` in place.** It's a dev-only preview helper (hides Next's red error overlay and relays build status to the builder preview) that no-ops in production. Don't remove or repurpose it.
201
200
  - **`localStorage` works** but is scoped per-origin — the app's own Vercel domain. Useful for caching, not for sharing state with the parent.
202
201
  - **Mobile-first layout with appropriate width.** The app renders in the iframe and must work down to 320 px. Design mobile-first: start with a single-column layout and scale up with `sm:`/`md:`/`lg:` breakpoints. **Content width depends on the app type:** most apps (tables, dashboards, boards) should use the full available width. Form-centric, single-column, or text-heavy apps that would look sparse at full width should center their content in a narrower column (`max-w-3xl mx-auto`). If the task description or plan specifies a content width, follow it; otherwise choose whichever looks better for the content. **When narrowing, place the `max-w-*` wrapper as the outermost page-level container** — outside `Tabs`, filter bars, and all other page chrome — so everything aligns to the same column. Do not narrow only the inner content of each tab panel while leaving `TabsList` or other siblings full-width.
@@ -327,7 +326,7 @@ Server components, route handlers, and server actions run in **Node on Vercel**
327
326
 
328
327
  - **Visual work** (draw a chart, render on a canvas) is **client** work — load the library with `next/dynamic` + `ssr: false`, never in server code.
329
328
  - **Fuzzy extraction** (pull fields from a document, classify, summarize) is usually better done through the **AI proxy** (see `assembly-ai`) than a parsing lib — no native binding, no serverless hazard.
330
- - **Genuine server-side data work** (transform an image, parse bytes) must use a Node-native library: add native/CommonJS packages (e.g. `sharp`) to `serverExternalPackages` in `next.config.ts` (bundling breaks the binding), and give any dep that's loaded dynamically or transitively a static, string-literal import in server code — the deploy tracer can't follow dynamic requires, so such a package silently won't exist in the deployed function even though preview works.
329
+ - **Genuine server-side data work** (transform an image, parse bytes) must use a Node-native library: add native/CommonJS packages (e.g. `sharp`) to `serverExternalPackages` in `app.config.ts` (bundling breaks the binding), and give any dep that's loaded dynamically or transitively a static, string-literal import in server code — the deploy tracer can't follow dynamic requires, so such a package silently won't exist in the deployed function even though preview works.
331
330
  - **Browser-only packages** (touch `window`/`document`/`DOMMatrix`/`HTMLCanvasElement` at import *or during operations*) can't run server-side at all — `serverExternalPackages` doesn't help and dynamic `import()` only defers the crash. Use a Node-native alternative or move the work client-side.
332
331
 
333
332
  ---
@@ -645,6 +644,8 @@ import { loadItems } from '@/app/actions/items';
645
644
  export default function MyWidget() {
646
645
  const { data: items, isLoading, error, reload } = useServerAction(() => loadItems(), []);
647
646
  // `deps` ([]) works like useEffect deps — list anything the action closes over.
647
+ // Deps must be primitives (an id, a cursor, `items.length`). A derived array or
648
+ // object is a new identity every render and spins the page in a fetch loop.
648
649
  }
649
650
  ```
650
651
 
@@ -1124,7 +1125,7 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
1124
1125
  - ❌ Don't hand-roll UI components (`<button>`, `<input>`, `<dialog>`, `<table>`, etc.) when a shadcn component exists. See the Design system section for install instructions. Using `Link`/`<a className={buttonVariants(...)}>` for a button-looking link is the intentional exception.
1125
1126
 
1126
1127
  - ❌ Don't add a manual close button (a `<DialogClose>` render or any X button) inside `DialogContent`. `DialogContent` already ships a built-in close button (`showCloseButton` defaults to `true`); adding another produces two overlapping X icons in the top-right corner. Only pass `showCloseButton={false}` when a custom affordance genuinely replaces the built-in one.
1127
- - ❌ Don't skip `allowedDevOrigins` in `next.config.ts`. The iframe preview will render static HTML but JavaScript never loads (no hydration, no interactivity). If you rewrite `next.config.ts`, read `$SANDBOX_HOST` from the shell env and add it as a literal string in the config.
1128
+ - ❌ Don't edit `next.config.ts` — it's the platform-owned wrapper that keeps the preview working (cross-origin hydration, WebSocket-blocked networks), and `bun run build` fails if it's rewritten. Your Next configuration belongs in `app.config.ts`.
1128
1129
  - ❌ Don't import a native or browser-only library into server code without the serverless setup — `bun run build` passes but the deployed route crashes at request time. Decide the layer first per "Server runs in serverless Node" above.
1129
1130
  - ❌ Don't use `Select` or `Combobox` to pick any Assembly person or organization record (clients, internal users, team members, staff, companies). Always use `UserCompanySelector` — in forms, filters, dropdowns, "assign to" controls, everywhere.
1130
1131
  - ❌ Don't use `Combobox` when the user asks for a "dropdown" or "select" — use `Select`. Only use `Combobox` when the user explicitly needs type-to-search/filter functionality for non-user/non-company data.
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ // Fails the build when next.config has been rewritten away from the
3
+ // platform-owned wrapper form. Dependency-free on purpose: it runs inside the
4
+ // generated app's sandbox before any deps beyond Node exist.
5
+ //
6
+ // next.config.ts is platform boilerplate: `export default withAssemblyDev(appConfig)`.
7
+ // The wrapper (from @assembly-js/app-foundation/next) applies the dev-preview
8
+ // invariants — allowedDevOrigins from the sandbox host, and disabling the React
9
+ // debug channel, whose loss silently hangs hydration forever on Next 16.3+ when
10
+ // the dev WebSocket can't connect. Agents legitimately need Next config
11
+ // (serverExternalPackages etc.) — that lives in app.config.ts, which the
12
+ // wrapper spreads. Prose rules get skipped; a failing build doesn't.
13
+ //
14
+ // An app without app.config.ts predates the wrapper split (cloned from an older
15
+ // foundation). There the wrapper form was never the contract, so this check
16
+ // exits 0 — those apps are protected by the boot/restart patch scripts instead.
17
+ import { readFileSync, existsSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+
20
+ const ROOT = process.cwd();
21
+ const CONFIG_FILES = ['next.config.ts', 'next.config.mjs', 'next.config.js'];
22
+ const APP_CONFIG_FILES = ['app.config.ts', 'app.config.mjs', 'app.config.js'];
23
+
24
+ if (!APP_CONFIG_FILES.some((f) => existsSync(join(ROOT, f)))) {
25
+ process.exit(0); // pre-wrapper app: not this check's contract
26
+ }
27
+
28
+ const present = CONFIG_FILES.filter((f) => existsSync(join(ROOT, f)));
29
+
30
+ // A second config file shadows the wrapper: Next loads only one of them, and
31
+ // its pick may be the unwrapped copy — so validating just the canonical file
32
+ // would bless a config the server never runs.
33
+ if (present.length > 1) {
34
+ console.error(
35
+ `check-next-config: multiple next.config files found (${present.join(', ')}). ` +
36
+ 'next.config.ts is the platform-owned wrapper — delete the other config file(s) ' +
37
+ 'and put your Next configuration in app.config.ts instead.',
38
+ );
39
+ process.exit(1);
40
+ }
41
+
42
+ const configFile = present[0];
43
+ if (!configFile) {
44
+ console.error(
45
+ 'check-next-config: no next.config.{ts,mjs,js} found. Restore the platform-owned wrapper:\n\n' +
46
+ ' import { withAssemblyDev } from "@assembly-js/app-foundation/next";\n' +
47
+ ' import appConfig from "./app.config";\n' +
48
+ ' export default withAssemblyDev(appConfig);\n',
49
+ );
50
+ process.exit(1);
51
+ }
52
+
53
+ const content = readFileSync(join(ROOT, configFile), 'utf8');
54
+ const importsWrapper = /from\s+['"]@assembly-js\/app-foundation\/next['"]/.test(content);
55
+ const exportsWrapped = /export\s+default\s+withAssemblyDev\s*\(/.test(content);
56
+
57
+ if (importsWrapper && exportsWrapped) {
58
+ process.exit(0);
59
+ }
60
+
61
+ console.error(
62
+ `check-next-config: ${configFile} is platform-owned and must stay in the wrapper form:\n\n` +
63
+ ' import { withAssemblyDev } from "@assembly-js/app-foundation/next";\n' +
64
+ ' import appConfig from "./app.config";\n' +
65
+ ' export default withAssemblyDev(appConfig);\n\n' +
66
+ 'Put your Next configuration (serverExternalPackages, experimental flags, …) in app.config.ts — ' +
67
+ 'the wrapper spreads it and applies the Assembly dev-preview invariants on top. ' +
68
+ 'Editing next.config.ts directly drops those invariants and permanently breaks the preview ' +
69
+ 'for users on WebSocket-blocking networks.',
70
+ );
71
+ process.exit(1);
@@ -21,6 +21,7 @@ export interface MintSessionOptions {
21
21
  cookieSessionId?: string;
22
22
  }
23
23
  export declare function mintBearerFromSessionToken(token: string, options?: MintSessionOptions): Promise<MintOutcome>;
24
+ export declare function fetchSessionFlags(bearer: string): Promise<string[] | null>;
24
25
  export interface MintedAppBearer {
25
26
  bearer: string;
26
27
  bearerExpiresAt: number;
@@ -90,6 +90,7 @@ export async function mintBearerFromSessionToken(token, options) {
90
90
  notificationId: json.session?.notificationId || undefined,
91
91
  instanceId: json.session?.instanceId || undefined,
92
92
  sessionId: payload.tokenId,
93
+ flags: json.flags?.length ? json.flags : undefined,
93
94
  },
94
95
  };
95
96
  }
@@ -102,6 +103,39 @@ export async function mintBearerFromSessionToken(token, options) {
102
103
  clearTimeout(timeoutId);
103
104
  }
104
105
  }
106
+ // Non-destructive read; a hung call only delays one cookie refresh, so give it
107
+ // far less rope than the exchange.
108
+ const FLAGS_TIMEOUT_MS = 5_000;
109
+ // Re-resolves the app-foundation-visible flags for the workspace behind an
110
+ // already-minted bearer (GET /v1/session/flags). The freshness half of the
111
+ // flag channel: the exchange response seeds the proxy's flags cookie at mint
112
+ // time, and this refreshes it on the cookie's own cadence — no re-mint, no
113
+ // auth churn. Returns null when the value could not be determined (network
114
+ // failure, expired bearer, malformed body) so the caller can keep whatever it
115
+ // has rather than treating the miss as "all flags off".
116
+ export async function fetchSessionFlags(bearer) {
117
+ const base = (mintBaseUrl ?? OpenAPI.BASE).replace(/\/$/, '');
118
+ const controller = new AbortController();
119
+ const timeoutId = setTimeout(() => controller.abort(), FLAGS_TIMEOUT_MS);
120
+ try {
121
+ const resp = await fetch(`${base}/v1/session/flags`, {
122
+ headers: { Authorization: `Bearer ${bearer}` },
123
+ signal: controller.signal,
124
+ });
125
+ if (!resp.ok)
126
+ return null;
127
+ const json = (await resp.json());
128
+ if (!Array.isArray(json.flags))
129
+ return null;
130
+ return json.flags.filter((f) => typeof f === 'string');
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ finally {
136
+ clearTimeout(timeoutId);
137
+ }
138
+ }
105
139
  // Mints a short-lived, APP-scoped bearer from the app API key ALONE by calling
106
140
  // platform-api POST /v1/token (client-credentials). There is NO session token
107
141
  // and NO user in the loop: this is the OFFLINE/background path (cron, webhooks)
package/dist/nango.d.ts CHANGED
@@ -12,8 +12,9 @@ export declare class ConnectorError extends Error {
12
12
  readonly status: number;
13
13
  /**
14
14
  * For `action_failed`: what the action script threw — usually the
15
- * provider-level failure, e.g. `{ type: 'slack_error', message, response }`
16
- * where `response.error` is Slack's error code.
15
+ * provider-level failure. Scripts disagree on where the provider's error
16
+ * code sits inside it (`response.error`, `error`, or `message`), so don't
17
+ * read it by hand: for Slack use `slackErrorCode(err)`.
17
18
  */
18
19
  readonly detail?: unknown;
19
20
  constructor(args: {
@@ -120,6 +121,49 @@ export interface ResolveResourceOpts {
120
121
  * }
121
122
  */
122
123
  export declare function resolveResource(provider: Provider, resolverAction: string, opts: ResolveResourceOpts): Promise<ResourcePage>;
124
+ /**
125
+ * Slack's error code (e.g. 'not_in_channel', 'channel_not_found',
126
+ * 'is_archived') from a failed Slack action, or undefined when `err` is not an
127
+ * action failure. Branch on this instead of reading `err.detail` yourself: the
128
+ * vendored Slack scripts put the code in different places (`response.error`,
129
+ * `error`, or `message`), and this reads them in that order.
130
+ *
131
+ * ```typescript
132
+ * try {
133
+ * await slack.action('get-conversation-history', { channel_id, limit: 10 });
134
+ * } catch (err) {
135
+ * if (slackErrorCode(err) === 'not_in_channel') {
136
+ * // join with the channel ID and retry once; private channels can't be
137
+ * // self-joined — tell the user to invite the bot instead
138
+ * }
139
+ * throw err;
140
+ * }
141
+ * ```
142
+ */
143
+ export declare function slackErrorCode(err: unknown): string | undefined;
144
+ export interface SlackChannel {
145
+ /** Channel ID (`C…`/`G…`) — what every other Slack call takes. */
146
+ id: string;
147
+ /** Channel name without the leading '#'. Slack keeps these lowercase. */
148
+ name: string;
149
+ /** True for private channels (listed only when the bot is a member and the connection has `groups:read`). */
150
+ isPrivate: boolean;
151
+ }
152
+ /**
153
+ * Every channel the bot can see, sorted by name. Use this to populate a
154
+ * channel dropdown instead of paging the `list-channels` action yourself: that
155
+ * action tops out at 200 per page and a large workspace needs dozens of
156
+ * sequential calls, which is a 20–30 second dropdown. This walks
157
+ * conversations.list at Slack's maximum page size and handles rate limits and
158
+ * the private-channel scope fallback. Not cached: the client carries no
159
+ * workspace identity to key on, and an app transfer briefly serves two
160
+ * workspaces from one deployment.
161
+ *
162
+ * Public channels are listed whether or not the bot has joined; private
163
+ * channels only when it is already a member. Listing a channel does not make
164
+ * it readable — see slackFindChannelId for the membership rules.
165
+ */
166
+ export declare function slackListChannels(slack: IntegrationClient): Promise<SlackChannel[]>;
123
167
  /**
124
168
  * Resolve a Slack channel name to its ID, walking the cursor-paginated
125
169
  * conversations.list until found (Slack treats `limit` as a per-page cap and
@@ -134,10 +178,11 @@ export declare function resolveResource(provider: Provider, resolverAction: stri
134
178
  * methods reject a `#name` with `invalid_arguments`.
135
179
  *
136
180
  * Membership: public channels are listed whether or not the bot joined, but
137
- * posting still needs membership — on a `not_in_channel` error from
138
- * chat.postMessage, call conversations.join with the ID and retry once.
139
- * Private channels are listed only when the bot is already a member (and only
140
- * when the connection has the groups:read scope — without it this helper
181
+ * everything except posting (`send-message`) needs membership — reading
182
+ * history, adding reactions, inviting users all fail `not_in_channel` until
183
+ * the bot joins. On that code, call `join-channel` with the ID and retry
184
+ * once. Private channels are listed only when the bot is already a member (and
185
+ * only when the connection has the groups:read scope — without it this helper
141
186
  * automatically falls back to public channels only). A private channel cannot
142
187
  * be self-joined: surface "invite the bot to #channel in Slack" to the user.
143
188
  */
package/dist/nango.js CHANGED
@@ -10,8 +10,9 @@ export class ConnectorError extends Error {
10
10
  status;
11
11
  /**
12
12
  * For `action_failed`: what the action script threw — usually the
13
- * provider-level failure, e.g. `{ type: 'slack_error', message, response }`
14
- * where `response.error` is Slack's error code.
13
+ * provider-level failure. Scripts disagree on where the provider's error
14
+ * code sits inside it (`response.error`, `error`, or `message`), so don't
15
+ * read it by hand: for Slack use `slackErrorCode(err)`.
15
16
  */
16
17
  detail;
17
18
  constructor(args) {
@@ -281,47 +282,40 @@ function sleep(ms) {
281
282
  return new Promise((resolve) => setTimeout(resolve, ms));
282
283
  }
283
284
  /**
284
- * Resolve a Slack channel name to its ID, walking the cursor-paginated
285
- * conversations.list until found (Slack treats `limit` as a per-page cap and
286
- * may return fewer; a single un-paginated call misses channels in large
287
- * workspaces). Input is normalized (leading '#' stripped, lowercased — Slack
288
- * channel names are always lowercase). Resolved IDs are memoized for the
289
- * process lifetime; Slack rate limits (conversations.list is a low-tier
290
- * method) are absorbed by waiting and resuming from the same cursor.
285
+ * Slack's error code (e.g. 'not_in_channel', 'channel_not_found',
286
+ * 'is_archived') from a failed Slack action, or undefined when `err` is not an
287
+ * action failure. Branch on this instead of reading `err.detail` yourself: the
288
+ * vendored Slack scripts put the code in different places (`response.error`,
289
+ * `error`, or `message`), and this reads them in that order.
291
290
  *
292
- * Use the returned ID for every follow-up call — conversations.join /
293
- * conversations.history / chat.postMessage should all be given IDs; most
294
- * methods reject a `#name` with `invalid_arguments`.
295
- *
296
- * Membership: public channels are listed whether or not the bot joined, but
297
- * posting still needs membership — on a `not_in_channel` error from
298
- * chat.postMessage, call conversations.join with the ID and retry once.
299
- * Private channels are listed only when the bot is already a member (and only
300
- * when the connection has the groups:read scope — without it this helper
301
- * automatically falls back to public channels only). A private channel cannot
302
- * be self-joined: surface "invite the bot to #channel in Slack" to the user.
291
+ * ```typescript
292
+ * try {
293
+ * await slack.action('get-conversation-history', { channel_id, limit: 10 });
294
+ * } catch (err) {
295
+ * if (slackErrorCode(err) === 'not_in_channel') {
296
+ * // join with the channel ID and retry once; private channels can't be
297
+ * // self-joined — tell the user to invite the bot instead
298
+ * }
299
+ * throw err;
300
+ * }
301
+ * ```
303
302
  */
304
- export async function slackFindChannelId(slack, name) {
305
- const target = name.replace(/^#/, '').trim().toLowerCase();
306
- if (!target)
307
- return null;
308
- const cached = slackChannelIdCache.get(target);
309
- if (cached)
310
- return cached;
311
- const fields = {
312
- id: 'id',
313
- label: 'name',
314
- items: 'channels',
315
- nextCursor: 'response_metadata.next_cursor',
316
- };
303
+ export function slackErrorCode(err) {
304
+ if (!(err instanceof ConnectorError) || err.code !== 'action_failed')
305
+ return undefined;
306
+ const detail = err.detail;
307
+ const code = detail?.response?.error ?? detail?.error ?? detail?.message;
308
+ return typeof code === 'string' ? code : undefined;
309
+ }
310
+ // One-page fetcher over conversations.list that owns the Slack quirks a naive
311
+ // walk gets wrong: rate-limit waits resume from the SAME cursor (throwing would
312
+ // discard the walk and a retry would restart from page 1), and a missing_scope
313
+ // for private_channel (no groups:read) fails the WHOLE first call, so retrying
314
+ // public-only from the same cursor restarts the walk correctly.
315
+ function slackChannelPageFetcher(slack) {
317
316
  let types = 'public_channel,private_channel';
318
317
  let rateLimitWaits = 0;
319
- // findResource drives the cursor walk; this fetcher owns the Slack quirks —
320
- // rate-limit waits resume from the SAME cursor (throwing would discard the
321
- // walk and a retry would restart from page 1), and a missing_scope for
322
- // private_channel (no groups:read) fails the WHOLE first call, so retrying
323
- // public-only from the same cursor restarts the walk correctly.
324
- const fetchPage = async (cursor) => {
318
+ return async (cursor) => {
325
319
  for (;;) {
326
320
  let res;
327
321
  try {
@@ -361,14 +355,90 @@ export async function slackFindChannelId(slack, name) {
361
355
  }
362
356
  throw new Error(data.error ?? 'Slack conversations.list failed');
363
357
  }
364
- return normalizeResourcePage(data, fields, { requestCursor: cursor });
358
+ const channels = [];
359
+ for (const channel of data.channels ?? []) {
360
+ if (!channel.id || !channel.name)
361
+ continue;
362
+ channels.push({
363
+ id: channel.id,
364
+ name: channel.name,
365
+ isPrivate: channel.is_private === true,
366
+ });
367
+ }
368
+ // Slack sends an empty string, not an absent key, on the last page; a
369
+ // cursor identical to the one just used would loop forever.
370
+ const next = data.response_metadata?.next_cursor || undefined;
371
+ return { channels, nextCursor: next && next !== cursor ? next : undefined };
365
372
  }
366
373
  };
374
+ }
375
+ /**
376
+ * Every channel the bot can see, sorted by name. Use this to populate a
377
+ * channel dropdown instead of paging the `list-channels` action yourself: that
378
+ * action tops out at 200 per page and a large workspace needs dozens of
379
+ * sequential calls, which is a 20–30 second dropdown. This walks
380
+ * conversations.list at Slack's maximum page size and handles rate limits and
381
+ * the private-channel scope fallback. Not cached: the client carries no
382
+ * workspace identity to key on, and an app transfer briefly serves two
383
+ * workspaces from one deployment.
384
+ *
385
+ * Public channels are listed whether or not the bot has joined; private
386
+ * channels only when it is already a member. Listing a channel does not make
387
+ * it readable — see slackFindChannelId for the membership rules.
388
+ */
389
+ export async function slackListChannels(slack) {
390
+ const fetchPage = slackChannelPageFetcher(slack);
391
+ const channels = [];
392
+ let cursor;
393
+ for (let page = 0; page < SLACK_LIST_MAX_PAGES; page++) {
394
+ const result = await fetchPage(cursor);
395
+ channels.push(...result.channels);
396
+ if (!result.nextCursor)
397
+ break;
398
+ cursor = result.nextCursor;
399
+ }
400
+ channels.sort((a, b) => a.name.localeCompare(b.name));
401
+ return channels;
402
+ }
403
+ /**
404
+ * Resolve a Slack channel name to its ID, walking the cursor-paginated
405
+ * conversations.list until found (Slack treats `limit` as a per-page cap and
406
+ * may return fewer; a single un-paginated call misses channels in large
407
+ * workspaces). Input is normalized (leading '#' stripped, lowercased — Slack
408
+ * channel names are always lowercase). Resolved IDs are memoized for the
409
+ * process lifetime; Slack rate limits (conversations.list is a low-tier
410
+ * method) are absorbed by waiting and resuming from the same cursor.
411
+ *
412
+ * Use the returned ID for every follow-up call — conversations.join /
413
+ * conversations.history / chat.postMessage should all be given IDs; most
414
+ * methods reject a `#name` with `invalid_arguments`.
415
+ *
416
+ * Membership: public channels are listed whether or not the bot joined, but
417
+ * everything except posting (`send-message`) needs membership — reading
418
+ * history, adding reactions, inviting users all fail `not_in_channel` until
419
+ * the bot joins. On that code, call `join-channel` with the ID and retry
420
+ * once. Private channels are listed only when the bot is already a member (and
421
+ * only when the connection has the groups:read scope — without it this helper
422
+ * automatically falls back to public channels only). A private channel cannot
423
+ * be self-joined: surface "invite the bot to #channel in Slack" to the user.
424
+ */
425
+ export async function slackFindChannelId(slack, name) {
426
+ const target = name.replace(/^#/, '').trim().toLowerCase();
427
+ if (!target)
428
+ return null;
429
+ const cached = slackChannelIdCache.get(target);
430
+ if (cached)
431
+ return cached;
432
+ const fetchPage = slackChannelPageFetcher(slack);
367
433
  // Slack channel names are always lowercase, so an exact label match is the
368
434
  // same comparison the raw walk made against channel.name.
369
- const hit = await findResource(fetchPage, (option) => option.label === target, {
370
- maxPages: SLACK_LIST_MAX_PAGES,
371
- });
435
+ const hit = await findResource(async (cursor) => {
436
+ const page = await fetchPage(cursor);
437
+ return {
438
+ options: page.channels.map((channel) => ({ id: channel.id, label: channel.name })),
439
+ nextCursor: page.nextCursor,
440
+ };
441
+ }, (option) => option.label === target, { maxPages: SLACK_LIST_MAX_PAGES });
372
442
  if (hit)
373
443
  slackChannelIdCache.set(target, hit.id);
374
444
  return hit?.id ?? null;
@@ -408,12 +478,9 @@ export async function slackPostMessage(slack, args) {
408
478
  return { ok: result.ok, ts: result.ts };
409
479
  }
410
480
  catch (err) {
411
- if (err instanceof ConnectorError && err.code === 'action_failed') {
412
- const detail = err.detail;
413
- const slackError = detail?.response?.error ?? detail?.message;
414
- if (typeof slackError === 'string')
415
- return { ok: false, error: slackError };
416
- }
481
+ const slackError = slackErrorCode(err);
482
+ if (slackError)
483
+ return { ok: false, error: slackError };
417
484
  throw err;
418
485
  }
419
486
  };
package/dist/next.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { NextConfig } from 'next';
2
+ export declare function withAssemblyDev(config?: NextConfig): NextConfig;
package/dist/next.js ADDED
@@ -0,0 +1,25 @@
1
+ export function withAssemblyDev(config = {}) {
2
+ // Production builds (deploys) have no dev server, no HMR socket, and no
3
+ // sandbox host — the wrapper is a pass-through there.
4
+ if (process.env.NODE_ENV === 'production')
5
+ return config;
6
+ const sandboxHost = process.env.SANDBOX_HOST;
7
+ const allowedDevOrigins = sandboxHost
8
+ ? [...new Set([sandboxHost, ...(config.allowedDevOrigins ?? [])])]
9
+ : config.allowedDevOrigins;
10
+ // A dev WebSocket that never connects would otherwise hang hydration forever
11
+ // on Next 16.3+. Debug data falls back to the flight stream, so
12
+ // nothing user-visible is lost. Cast because this package type-checks against
13
+ // its Next 15 devDependency, which predates the flag; runtimes that predate
14
+ // it ignore the unknown key.
15
+ const experimental = {
16
+ ...config.experimental,
17
+ reactDebugChannel: false,
18
+ };
19
+ return {
20
+ ...config,
21
+ ...(allowedDevOrigins ? { allowedDevOrigins } : {}),
22
+ devIndicators: config.devIndicators ?? false,
23
+ experimental,
24
+ };
25
+ }
@@ -20,5 +20,17 @@ export interface ExchangedSession {
20
20
  /** Session token this bearer was exchanged from — lets the proxy tell a
21
21
  * reload of an already-exchanged token apart from a new open. */
22
22
  sessionId?: string;
23
+ /** Keys of whichever PostHog flags — from platform-api's small, explicit
24
+ * app-foundation-visible-flags allowlist (session.go's appFoundationVisibleFlags) — are on
25
+ * for this workspace. Not every flag: most are irrelevant or sensitive and
26
+ * never reach this channel. Sourced from the exchange RESPONSE at mint time
27
+ * (see mint-bearer.ts), deliberately not a bearer claim — flags are
28
+ * configuration with their own freshness (refresh via fetchSessionFlags),
29
+ * so decodeBearer never populates this. Use hasFlag() to check one; any
30
+ * AND/OR combination is the caller's own logic over this raw set. */
31
+ flags?: string[];
23
32
  }
33
+ /** Whether a decoded session carries the given flag key. Null-safe so callers
34
+ * can chain straight off decodeBearer()'s result without a separate guard. */
35
+ export declare function hasFlag(session: ExchangedSession | null, key: string): boolean;
24
36
  export declare function decodeBearer(bearer: string): ExchangedSession | null;
@@ -73,6 +73,11 @@ export function parseExchangeRejection(value) {
73
73
  export function isSessionExchangeEnabled() {
74
74
  return process.env.ENABLE_APP_SESSION_TOKEN_EXCHANGE === 'true';
75
75
  }
76
+ /** Whether a decoded session carries the given flag key. Null-safe so callers
77
+ * can chain straight off decodeBearer()'s result without a separate guard. */
78
+ export function hasFlag(session, key) {
79
+ return session?.flags?.includes(key) ?? false;
80
+ }
76
81
  // Decodes a base64url JWT segment to a UTF-8 string using only Web APIs (atob,
77
82
  // TextDecoder), so decodeBearer runs in BOTH the Edge middleware runtime (proxy)
78
83
  // and the Node route-handler runtime (requireAuth / getAssemblyForRequest) —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -22,6 +22,10 @@
22
22
  "./lib-client": {
23
23
  "types": "./dist/lib-client.d.ts",
24
24
  "default": "./dist/lib-client.js"
25
+ },
26
+ "./next": {
27
+ "types": "./dist/next.d.ts",
28
+ "default": "./dist/next.js"
25
29
  }
26
30
  },
27
31
  "scripts": {
@@ -98,7 +98,7 @@ A connector call throws a **`ConnectorError`** (with `.code`, `.provider`, `.sta
98
98
  - `connection_required` / `needs_reauth` / `needs_reauth_scope` — the workspace admin needs to (re)connect the provider in app settings. Render a "needs setup / reconnect" state, not a crash.
99
99
  - `connection_throttled` — too many calls on the shared connection; back off and retry.
100
100
  - `connector_unavailable` — the provider or Nango is temporarily unavailable.
101
- - `action_failed` — the pre-built action ran but the provider rejected the operation. The provider-level failure is in `err.detail` (e.g. `detail.response.error` is Slack's error code) — branch on it the way you would on a proxy response's error field.
101
+ - `action_failed` — the pre-built action ran but the provider rejected the operation. The provider-level failure is in `err.detail`, but scripts disagree on where the provider's code sits inside it, so never read `detail` by hand. For Slack call `slackErrorCode(err)` (returns e.g. `'not_in_channel'`, `'is_archived'`) and branch on that; for other providers branch on `err.detail` the way you would on a proxy response's error field.
102
102
  - `app_key_required` / `provider_not_declared` — a wiring bug: you called from an unauthenticated scope, or didn't declare the provider in `requiredIntegrations`. Fix the call site / manifest.
103
103
  - `invalid_request` — malformed relay input (bad action name, unreadable body). Fix the call site.
104
104
 
@@ -1,25 +1,41 @@
1
1
  # Slack
2
2
 
3
- Writes need membership, not just visibility. `send-message` reaches public channels without joining; other writes fail `not_in_channel` until the bot joins.
3
+ Everything except `send-message` needs membership, not just visibility. Posting reaches any public channel without joining (`chat:write.public`); reading history, adding reactions, and inviting users all fail `not_in_channel` until the bot joins.
4
4
 
5
5
  - **Post 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).
6
+ - **List channels with `slackListChannels` from `@assembly-js/app-foundation/server` — never page the `list-channels` action yourself.** That action returns at most 200 per page, so a large workspace is dozens of sequential calls and a 20–30 second dropdown; the helper walks at Slack's maximum page size and handles rate limits and the private-channel scope fallback. It returns `{ id, name, isPrivate }` sorted by name.
6
7
  - **Resolve channel names with `slackFindChannelId` from `@assembly-js/app-foundation/server` for every other per-channel call.** It handles cursor pagination (a single `conversations.list` misses channels in large workspaces), name normalization, scope fallback, rate limits, and caching. Use the returned ID; most Slack methods reject a `#name` with `invalid_arguments`.
7
- - **Other writes (reactions, invites):** on `not_in_channel`, call `join-channel` with the channel ID, then retry once. Private channels fail `method_not_supported_for_channel_type` — tell the user to invite the bot instead of retrying.
8
- - **Scopes:** `chat:write.public` lets posting reach any public channel without joining, so only the other writes need `join-channel`. Add `groups:read` if the app must find private channels.
8
+ - **Read the Slack error code with `slackErrorCode(err)` from `@assembly-js/app-foundation/server` — never read `err.detail` yourself.** The vendored scripts put the code in different places inside `detail`; the helper returns it (`'not_in_channel'`, `'channel_not_found'`, `'is_archived'`, …) or `undefined` when the error isn't a Slack rejection.
9
+ - **Every other per-channel call (history, reactions, invites):** when `slackErrorCode(err) === 'not_in_channel'`, call `join-channel` with the channel ID, then retry once. Private channels fail the join with `method_not_supported_for_channel_type` — tell the user to invite the bot instead of retrying.
10
+ - **Scopes:** `chat:write.public` lets posting reach any public channel without joining, so only the other calls need `join-channel` (which needs `channels:join`). Add `groups:read` if the app must find private channels.
9
11
 
10
12
  ```typescript
11
- import { getIntegration, slackFindChannelId, slackPostMessage } from '@assembly-js/app-foundation/server';
13
+ import {
14
+ getIntegration,
15
+ slackErrorCode,
16
+ slackFindChannelId,
17
+ slackListChannels,
18
+ slackPostMessage,
19
+ } from '@assembly-js/app-foundation/server';
12
20
 
13
21
  const slack = await getIntegration('slack');
22
+ const channels = await slackListChannels(slack); // dropdown options
14
23
  const channelId = await slackFindChannelId(slack, 'general');
15
24
  const posted = await slackPostMessage(slack, { channel: '#general', text: 'Hello' });
16
25
  ```
17
26
 
18
27
  ```typescript
19
- const history = await slack.action('get-conversation-history', {
20
- channel_id: channelId,
21
- limit: 10,
22
- });
28
+ // Reads need membership too: join on not_in_channel and retry once.
29
+ async function history(channelId: string) {
30
+ const input = { channel_id: channelId, limit: 10 };
31
+ try {
32
+ return await slack.action('get-conversation-history', input);
33
+ } catch (err) {
34
+ if (slackErrorCode(err) !== 'not_in_channel') throw err;
35
+ await slack.action('join-channel', { channel_id: channelId });
36
+ return slack.action('get-conversation-history', input);
37
+ }
38
+ }
23
39
  ```
24
40
 
25
41
  For an operation no action covers, relay through the proxy — e.g. `slack.post({ endpoint: '/chat.update', data: { channel: channelId, ts, text } })`.
@@ -66,6 +66,8 @@ export function ClientList() {
66
66
  }
67
67
  ```
68
68
 
69
+ `deps` works like a `useEffect` dependency array, with one rule: **deps must be primitives** — an id, a cursor, a `length`, a boolean. A derived array or object (`records`, `{ baseId }`, `items.filter(...)`) is a new identity on every render, so the action re-runs, its state update re-renders, and the page spins forever without a network call or an error to point at. Pass `records.length` or the id the array came from instead.
70
+
69
71
  `deps` works like a `useEffect` dependency array — list any values your action closes over so it re-runs when they change.
70
72
 
71
73
  For an imperative one-off (a button handler, a mutation), use `callAction` from `useAssembly()` — same re-exchange-and-retry behavior, no hook: