@assembly-js/app-foundation 0.0.25-rc.2 → 0.0.25-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -120,7 +120,12 @@ The app should **detect the viewer type from the token** and render audience-app
120
120
 
121
121
  **The CRM tab is never the only path to per-client data.** The CRM tab is a shortcut: it renders with a specific `clientId`/`companyId` already in the token. But any client/company-specific data or action it exposes must *also* be reachable from the Dashboard main view — even if it takes a few clicks. Build the main view so an internal user can browse to a specific client/company (a list, or a `UserCompanySelector` that drills into a per-entity detail screen) and reach the same data and actions there. The main-view detail screen doesn't have to match the CRM-tab layout — it can reuse the same components or present them differently — but the main view must never be empty or aggregate-only when the CRM tab has per-client functionality. Don't leave `internalUrl` as a dead end.
122
122
 
123
+ <!-- !feature:app-owned-header -->
123
124
  **More generally: every page you create needs a click path.** Next.js builds and typechecks a page nothing links to, so an orphaned route ships silently. Before a page is done, something on an already-reachable page must point at it — a `<Link>`, `router.push`, redirect, header CTA, or actions-menu item. `bun run build` enforces this (`bun run check:route-reachability` runs it alone). When a primary flow depends on managing another resource first (cases need stages, documents need templates), ship that manager's entry point together with the dependent flow — an actions-menu item on the main view plus a link on the empty state that blocks on it.
125
+ <!-- /feature:app-owned-header -->
126
+ <!-- feature:app-owned-header -->
127
+ **More generally: every page you create needs a click path.** Next.js builds and typechecks a page nothing links to, so an orphaned route ships silently. Before a page is done, something on an already-reachable page must point at it — a `<Link>`, `router.push`, redirect, or actions-menu item. `bun run build` enforces this (`bun run check:route-reachability` runs it alone). When a primary flow depends on managing another resource first (cases need stages, documents need templates), ship that manager's entry point together with the dependent flow — an actions-menu item on the main view plus a link on the empty state that blocks on it.
128
+ <!-- /feature:app-owned-header -->
124
129
 
125
130
  ### Per-client app visibility — platform setting, not the app's job
126
131
 
@@ -186,8 +191,24 @@ Because the app runs in an iframe inside the parent dashboard, several normal Ne
186
191
  - **No `window.open(..., '_self')`.** Same issue.
187
192
  - **The portal owns the chrome (header, sidebar).** Don't render your own breadcrumbs or page titles via DOM. Use `@assembly-js/app-bridge` to set them.
188
193
  - **Never put the app name in breadcrumbs.** The parent portal *automatically* prepends the app's name as breadcrumb 0. If you also pass `{ label: 'MyApp' }` as the first entry of `setBreadcrumbs`, it renders twice ("MyApp / MyApp / Reports"). For a flat one-page app pass `[]` (or skip `setBreadcrumbs` entirely). For a multi-level app pass only the sub-pages: `setBreadcrumbs([{ label: 'Reports' }, { label: 'Q4 2026' }])`. Read `.claude/skills/assembly-app-bridge-header/SKILL.md` for full examples.
194
+ <!-- !feature:app-owned-header -->
189
195
  - **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.
190
196
  **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.
197
+ <!-- /feature:app-owned-header -->
198
+ <!-- feature:app-owned-header -->
199
+
200
+ ### Your rendering surface
201
+
202
+ The app renders inside the Assembly dashboard iframe — global nav to the left, the app as the main area in the right column owning its full height, including the top. **There is no host header above the app; nothing prepends the app name.**
203
+
204
+ **The app owns its header.** Decide whether to have one at all and what goes in it: title, navigation, primary and secondary actions. Do not call `useAssemblyHeader` or `AssemblyBridge.header.*` — nothing listens to those calls.
205
+
206
+ Assembly's own conventions, offered as reference and not as a template: page-level primary CTAs sit at the top right of a page header and sometimes inline within the content; breadcrumbs, when present, sit top-left; the dashboard uses a 60 px header row with a bottom border when it has one.
207
+
208
+ **When deciding the header, title, navigation, and primary actions, prefer in this order:** (1) the user's stated preferences, (2) a light adherence to Assembly's conventions where they make sense for this app, (3) your own judgment for the app.
209
+
210
+ **The `/n` notification surface is unchanged.** Render primary actions as inline `<Button>` elements in the page body — the Notification Center has no header regardless of this flag.
211
+ <!-- /feature:app-owned-header -->
191
212
  - **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.
192
213
  - **`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`:
193
214
  ```ts
@@ -369,7 +390,12 @@ If `tsc --noEmit` is clean and the task says "verify the build", run `bun run bu
369
390
 
370
391
  `bun run build` also **fails** if any page reads `searchParams.token`. Auth is in the bearer cookie, not the URL — no page (not even a surface entry page) needs it; `getAssembly()` / `getSessionIdentity()` read the bearer. Fix as the error instructs — fetch that page's data via `useServerAction`/`authFetch` instead. Run `bun run check:token-usage` to check it on its own.
371
392
 
393
+ <!-- !feature:app-owned-header -->
372
394
  `bun run build` also **fails** if a page route is unreachable — no link, `router.push`, redirect, or header CTA anywhere else points at it. Fix as the error instructs by adding an entry point on a reachable page. Run `bun run check:route-reachability` to check it on its own.
395
+ <!-- /feature:app-owned-header -->
396
+ <!-- feature:app-owned-header -->
397
+ `bun run build` also **fails** if a page route is unreachable — no link, `router.push`, redirect, or other entry point elsewhere points at it. Fix as the error instructs by adding an entry point on a reachable page. Run `bun run check:route-reachability` to check it on its own.
398
+ <!-- /feature:app-owned-header -->
373
399
 
374
400
  ---
375
401
 
@@ -1081,10 +1107,12 @@ Pass `limit: 100` (the max) so the walk makes 5x fewer round trips than the ~20
1081
1107
  ## Client-side SDK: `@assembly-js/app-bridge`
1082
1108
 
1083
1109
  For controlling the portal chrome from your iframe. Use **only in client components** (`'use client'`).
1110
+ <!-- !feature:app-owned-header -->
1084
1111
 
1085
1112
  ### Header chrome
1086
1113
 
1087
1114
  Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.claude/skills/assembly-app-bridge-header/SKILL.md` for the full API and code examples. `AssemblyBridge` is a singleton — no init required. Use only in `'use client'` components.
1115
+ <!-- /feature:app-owned-header -->
1088
1116
 
1089
1117
  ### Session token — the foundation uses this, not you
1090
1118
 
@@ -1102,9 +1130,11 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
1102
1130
  - ❌ Don't reinvent built-in Assembly features (Messages, Files, Forms, Tasks, Billing) — call the SDK methods listed above.
1103
1131
  - ❌ Don't use `assembly.clients.list()` — the SDK is flat. Use `assembly.listClients(...)`.
1104
1132
  - ❌ Don't cache decoded payloads for >30s — token rotates every 5 min and the user could be different on the next call (rare, but possible if a session is shared across tabs).
1133
+ <!-- !feature:app-owned-header -->
1105
1134
  - ❌ Don't render your own breadcrumbs / titles in the DOM — use `AssemblyBridge.header.*`.
1106
1135
  - ❌ Don't render primary page-level actions (Submit, Save, Edit, Create) as `<Button>` elements inside the page body. These MUST go in the header via the `useAssemblyHeader` hook's `primaryCta`. Contextual inline actions (e.g. row-level edit icons, list "Add item" buttons) may stay in-page. In-page primary CTAs make the UX inconsistent with the rest of the portal. **Exception: the notification surface (`/n`).** The Notification Center renders `/n` in a headerless iframe — `useAssemblyHeader` is ignored. On this surface, primary actions MUST be inline `<Button>` elements.
1107
1136
  - ❌ Don't call `AssemblyBridge.header.set*` from your own `useEffect`. Always go through `useAssemblyHeader` — hand-rolling the imperative API (split set/teardown effects, or an unstable dep like an inline closure created fresh each render) nulls a CTA another effect owns, and the button disappears after the first re-render.
1137
+ <!-- /feature:app-owned-header -->
1108
1138
  - ❌ Don't let two mounted components call `useAssemblyHeader` at once — e.g. a parent shell's scaffolding-era stub CTA left in place after a child took real ownership. Strict Mode hides the race in dev; the hook throws in dev and ignores the second caller in production. Delete the parent's stub the moment a child owns the slot.
1109
1139
  - ❌ Don't `window.open(url, '_self')` or set `window.location` for external links — use `<a target="_blank" rel="noreferrer">`.
1110
1140
  - ❌ Don't hardcode brand colors. The portal applies the workspace theme.
@@ -1150,6 +1180,8 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
1150
1180
  3. **Call `getAssembly()` — no token argument, ever.** `getAssembly()` (RSC/server actions) and `getAssemblyForRequest(req)` (route handlers) read the bearer cookie. Identity via `getSessionIdentity()` / `useAssembly()`. Never read `searchParams.token`, thread a token, or set `x-assembly-token`.
1151
1181
  4. **The SDK is flat: `assembly.listClients(...)`, not `assembly.clients.list()`.** `dist/codegen/api/index.d.ts` lists every service; `services/<Resource>Service.d.ts` has the parameter shapes.
1152
1182
  5. **The bearer expires in 60 min; `useServerAction`/`authFetch` re-exchange it silently and retry.** You don't refresh anything or touch `AssemblyBridge.sessionToken`.
1183
+ <!-- !feature:app-owned-header -->
1153
1184
  6. **Use `AssemblyBridge.header.*`** for breadcrumbs/CTAs in client components. Don't render your own. (Exception: the notification surface `/n` has no header — render CTAs as inline buttons there.)
1185
+ <!-- /feature:app-owned-header -->
1154
1186
  7. **Don't reinvent Messages/Files/Forms/Tasks/Billing.** Wire them up via SDK.
1155
1187
  8. **Keep infrastructure plumbing out of chat.** Don't volunteer ASSEMBLY_ENV, COPILOT_ENV, token encryption, API key internals, or internal file names unprompted. Mid-build SDK auth warnings are expected — never surface them to the user. (Answering a user's direct technical question is fine.)
package/dist/client.d.ts CHANGED
@@ -2,6 +2,7 @@ export * from './session-exchange.js';
2
2
  export * from './network-capture.js';
3
3
  export * from './paint-beacon.js';
4
4
  export * from './origin-bootstrap.js';
5
+ export * from './cookieless-bearer.js';
5
6
  export * from './client-companies.js';
6
7
  export * from './pagination.js';
7
8
  export * from './theme.js';
package/dist/client.js CHANGED
@@ -12,6 +12,8 @@ export * from './paint-beacon.js';
12
12
  // origin with the bridge allowlist, and schedule the post-paint beacon. See
13
13
  // each module's comment.
14
14
  export * from './origin-bootstrap.js';
15
+ // In-memory bearer for browsers that refuse the cookie; inert everywhere else.
16
+ export * from './cookieless-bearer.js';
15
17
  export * from './client-companies.js';
16
18
  export * from './pagination.js';
17
19
  export * from './theme.js';
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Whether the browser is refusing to store the bearer cookie. Either the probe
3
+ * or the portal-origin cookie being readable proves the jar works — proxies that
4
+ * predate the probe write only the latter. A false "blocked" costs at most one
5
+ * mint, since a working cookie keeps authenticating regardless. Off-browser it
6
+ * returns false: no cookie jar is not the same as a blocked one.
7
+ */
8
+ export declare function cookiesAreBlocked(cookieString?: string): boolean;
9
+ /** The in-memory bearer, or null when the cookie path is carrying auth. */
10
+ export declare function getCookielessBearer(): string | null;
11
+ /**
12
+ * Adopt a freshly minted bearer for the cookie-less path and make sure the
13
+ * fetch patch is live. Called after a re-exchange returns one in its body.
14
+ */
15
+ export declare function setCookielessBearer(value: string | null): void;
16
+ /**
17
+ * Headers that ask the re-exchange route to return the bearer in its body.
18
+ * Shared with the renewal path in lib-client so both describe the cookie-less
19
+ * request the same way.
20
+ */
21
+ export declare function cookielessReexchangeHeaders(): Record<string, string>;
@@ -0,0 +1,93 @@
1
+ 'use client';
2
+ // Carries the bearer for browsers that refuse to store it: Safari blocks every
3
+ // cross-site-iframe cookie and has no CHIPS, so the cookie is dropped on write
4
+ // and only the first paint (which gets the bearer as a request header) works.
5
+ //
6
+ // window.fetch is the patch point because Next routes RSC navigations, server
7
+ // actions and app fetches through it alike, and server actions cannot carry a
8
+ // custom header any other way. The bearer stays in memory so it cannot outlive
9
+ // the document or leak into history.
10
+ import { APP_CSRF_HEADER, BEARER_IN_BODY_HEADER, CLIENT_BEARER_HEADER, COOKIE_PROBE_NAME, } from './session-exchange.js';
11
+ import { PORTAL_ORIGIN_COOKIE_NAME, readCookie } from './origin-bootstrap.js';
12
+ let bearer = null;
13
+ let patched = false;
14
+ /**
15
+ * Whether the browser is refusing to store the bearer cookie. Either the probe
16
+ * or the portal-origin cookie being readable proves the jar works — proxies that
17
+ * predate the probe write only the latter. A false "blocked" costs at most one
18
+ * mint, since a working cookie keeps authenticating regardless. Off-browser it
19
+ * returns false: no cookie jar is not the same as a blocked one.
20
+ */
21
+ export function cookiesAreBlocked(cookieString) {
22
+ if (cookieString === undefined && typeof document === 'undefined') {
23
+ return false;
24
+ }
25
+ return (readCookie(COOKIE_PROBE_NAME, cookieString) === null &&
26
+ readCookie(PORTAL_ORIGIN_COOKIE_NAME, cookieString) === null);
27
+ }
28
+ /** The in-memory bearer, or null when the cookie path is carrying auth. */
29
+ export function getCookielessBearer() {
30
+ return bearer;
31
+ }
32
+ /**
33
+ * Adopt a freshly minted bearer for the cookie-less path and make sure the
34
+ * fetch patch is live. Called after a re-exchange returns one in its body.
35
+ */
36
+ export function setCookielessBearer(value) {
37
+ bearer = value || null;
38
+ if (bearer)
39
+ patchFetch();
40
+ }
41
+ // Same-origin only: the bearer must never ride a request to a third party the
42
+ // app happens to call. A relative URL resolves against the document, so it is
43
+ // same-origin by construction.
44
+ function isSameOrigin(input) {
45
+ if (typeof window === 'undefined')
46
+ return false;
47
+ try {
48
+ const raw = typeof input === 'string'
49
+ ? input
50
+ : input instanceof URL
51
+ ? input.href
52
+ : input.url;
53
+ return new URL(raw, window.location.href).origin === window.location.origin;
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ // Patched once, and only after the cookie has been proven unavailable. A
60
+ // Request argument carries immutable headers, so it is rebuilt rather than
61
+ // mutated. The CSRF header rides along because a patched request may be a
62
+ // server action, which cannot set it itself.
63
+ function patchFetch() {
64
+ if (patched || typeof window === 'undefined')
65
+ return;
66
+ patched = true;
67
+ const originalFetch = window.fetch.bind(window);
68
+ window.fetch = function assemblyCookielessFetch(input, init) {
69
+ if (!bearer || !isSameOrigin(input)) {
70
+ return originalFetch(input, init);
71
+ }
72
+ if (input instanceof Request && !init) {
73
+ const headers = new Headers(input.headers);
74
+ headers.set(CLIENT_BEARER_HEADER, bearer);
75
+ headers.set(APP_CSRF_HEADER, '1');
76
+ return originalFetch(new Request(input, { headers }));
77
+ }
78
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
79
+ headers.set(CLIENT_BEARER_HEADER, bearer);
80
+ headers.set(APP_CSRF_HEADER, '1');
81
+ return originalFetch(input, { ...init, headers });
82
+ };
83
+ }
84
+ /**
85
+ * Headers that ask the re-exchange route to return the bearer in its body.
86
+ * Shared with the renewal path in lib-client so both describe the cookie-less
87
+ * request the same way.
88
+ */
89
+ export function cookielessReexchangeHeaders() {
90
+ return bearer
91
+ ? { [CLIENT_BEARER_HEADER]: bearer, [BEARER_IN_BODY_HEADER]: '1' }
92
+ : { [BEARER_IN_BODY_HEADER]: '1' };
93
+ }
@@ -1,2 +1,10 @@
1
1
  export declare function reexchangeBearer(): Promise<boolean>;
2
2
  export declare function authFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
3
+ /**
4
+ * Mint an in-memory bearer at load when the browser is refusing to store the
5
+ * cookie, so the first client-side navigation is already authenticated instead
6
+ * of 404ing its way into a renewal. A no-op wherever the cookie works.
7
+ *
8
+ * Exported for tests; runs once at module load in the browser.
9
+ */
10
+ export declare function bootstrapCookielessBearer(): Promise<boolean>;
@@ -6,6 +6,7 @@
6
6
  // (proxy.ts pulls constants from that barrel).
7
7
  import { AssemblyBridge } from '@assembly-js/app-bridge';
8
8
  import { APP_CSRF_HEADER, logRenewOutcome, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
9
+ import { cookielessReexchangeHeaders, cookiesAreBlocked, getCookielessBearer, setCookielessBearer, } from './cookieless-bearer.js';
9
10
  const REEXCHANGE_PATH = '/api/assembly-reexchange';
10
11
  // Single-flight gate for renewals. Session tokens are single-use for exchange:
11
12
  // when the 60-minute bearer expires, every in-flight fetch on the page 401s at
@@ -18,8 +19,8 @@ let renewalInFlight = null;
18
19
  // for the freshest session token (the parent keeps it alive) and posts it to the
19
20
  // re-exchange route, which mints a new bearer and sets the HttpOnly cookie — no
20
21
  // page reload. Returns true iff a fresh bearer was set. This is the only place
21
- // app code touches the raw session token, and only to re-mint; it is never sent
22
- // to the platform API.
22
+ // app code touches the raw session token, to re-mint and keep the URL aligned
23
+ // with the cookie; it is never sent to the platform API.
23
24
  export function reexchangeBearer() {
24
25
  if (renewalInFlight)
25
26
  return renewalInFlight;
@@ -30,6 +31,13 @@ export function reexchangeBearer() {
30
31
  return renewal;
31
32
  }
32
33
  async function renewBearer() {
34
+ const previousUrlToken = typeof window === 'undefined'
35
+ ? null
36
+ : new URL(window.location.href).searchParams.get('token');
37
+ // Re-read per attempt rather than caching: the first call on a cookie-less
38
+ // browser is the bootstrap (no bearer yet), and the probe is the only thing
39
+ // that distinguishes it from a browser whose cookie simply expired.
40
+ const cookieless = cookiesAreBlocked();
33
41
  let token = AssemblyBridge.sessionToken.getCurrent()?.token ?? '';
34
42
  // Proactively refresh: we're recovering from an expiry, so avoid re-minting
35
43
  // from a session token that may itself be stale.
@@ -43,22 +51,50 @@ async function renewBearer() {
43
51
  bridgeRefreshFailed = true;
44
52
  }
45
53
  if (!token) {
46
- logRenewOutcome('no_token', { bridgeRefreshFailed });
54
+ logRenewOutcome('no_token', { bridgeRefreshFailed, cookieless });
47
55
  return false;
48
56
  }
49
57
  try {
50
58
  const res = await fetch(REEXCHANGE_PATH, {
51
59
  method: 'POST',
52
- headers: { 'x-assembly-token': token, [APP_CSRF_HEADER]: '1' },
60
+ headers: {
61
+ 'x-assembly-token': token,
62
+ [APP_CSRF_HEADER]: '1',
63
+ ...(cookieless ? cookielessReexchangeHeaders() : {}),
64
+ },
53
65
  });
54
66
  logRenewOutcome(res.ok ? 'renewed' : 'rejected', {
55
67
  bridgeRefreshFailed,
56
68
  status: res.status,
69
+ cookieless,
57
70
  });
71
+ // On the cookie-less path the fresh bearer comes back in the body, because
72
+ // the Set-Cookie beside it is dropped exactly like the first one was. A body
73
+ // without one is a route that predates this path answering the ordinary way:
74
+ // the cookie was renewed if this browser keeps cookies, so the route's own
75
+ // verdict stands rather than failing a renewal that succeeded.
76
+ if (res.ok && cookieless) {
77
+ const body = await res.json().catch(() => null);
78
+ if (body?.bearer)
79
+ setCookielessBearer(body.bearer);
80
+ }
81
+ if (res.ok && previousUrlToken !== null) {
82
+ try {
83
+ const url = new URL(window.location.href);
84
+ // Don't overwrite a new preview session opened while renewal was in flight.
85
+ if (url.searchParams.get('token') === previousUrlToken) {
86
+ url.searchParams.set('token', token);
87
+ window.history.replaceState(window.history.state, '', url.href);
88
+ }
89
+ }
90
+ catch {
91
+ // A history update failure must not invalidate the cookie just renewed.
92
+ }
93
+ }
58
94
  return res.ok;
59
95
  }
60
96
  catch {
61
- logRenewOutcome('unavailable', { bridgeRefreshFailed });
97
+ logRenewOutcome('unavailable', { bridgeRefreshFailed, cookieless });
62
98
  return false;
63
99
  }
64
100
  }
@@ -90,3 +126,21 @@ export async function authFetch(input, init) {
90
126
  }
91
127
  return fetch(input, { ...init, headers });
92
128
  }
129
+ /**
130
+ * Mint an in-memory bearer at load when the browser is refusing to store the
131
+ * cookie, so the first client-side navigation is already authenticated instead
132
+ * of 404ing its way into a renewal. A no-op wherever the cookie works.
133
+ *
134
+ * Exported for tests; runs once at module load in the browser.
135
+ */
136
+ export function bootstrapCookielessBearer() {
137
+ if (typeof window === 'undefined')
138
+ return Promise.resolve(false);
139
+ if (!cookiesAreBlocked() || getCookielessBearer()) {
140
+ return Promise.resolve(false);
141
+ }
142
+ return reexchangeBearer();
143
+ }
144
+ // Self-running, mirroring origin-bootstrap: module evaluation precedes
145
+ // hydration, so the bearer is in hand before any component's first fetch.
146
+ void bootstrapCookielessBearer();
@@ -55,7 +55,13 @@ export async function mintBearerFromSessionToken(token, options) {
55
55
  try {
56
56
  const resp = await assembly.fetch('/v1/session', {
57
57
  method: 'POST',
58
- headers: { 'Content-Type': 'application/json' },
58
+ headers: {
59
+ 'Content-Type': 'application/json',
60
+ // Let /session validate the token in the body. The SDK's three-part
61
+ // key makes the authorizer reject expired tokens with a generic 403
62
+ // before the handler can distinguish a replay from an outage.
63
+ 'X-API-Key': `${payload.workspaceId}/${process.env.ASSEMBLY_API_KEY ?? ''}`,
64
+ },
59
65
  body: JSON.stringify({
60
66
  sessionId: payload.tokenId,
61
67
  ...(options?.cookieSessionId
@@ -17,9 +17,22 @@ export const PAINTED_BEACON_PATH = '/__assembly/painted';
17
17
  export const PAINTED_BEACON_EVENT = 'assembly.painted';
18
18
  let scheduled = false;
19
19
  let fired = false;
20
+ // Set by the app's error boundaries while they are the document's content.
21
+ // A paint of the error boundary is not a paint of the app, and the parent
22
+ // treats this beacon as proof the app is up — so it stays quiet and releases
23
+ // the schedule latch, so the boundary can re-install it once the app has
24
+ // rendered in its place.
25
+ const ERROR_DOCUMENT_FLAG = '__assemblyErrorDocument';
26
+ function isErrorDocument() {
27
+ return window[ERROR_DOCUMENT_FLAG] === true;
28
+ }
20
29
  function firePaintBeacon() {
21
30
  if (fired)
22
31
  return;
32
+ if (isErrorDocument()) {
33
+ scheduled = false;
34
+ return;
35
+ }
23
36
  fired = true;
24
37
  if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
25
38
  navigator.sendBeacon(PAINTED_BEACON_PATH);
@@ -5,13 +5,17 @@ export declare function classifyExchangeFailure(status: number): MintFailure;
5
5
  export declare function serializeBearerCookie(value: string, maxAgeSeconds: number, partitioned: boolean): string;
6
6
  export declare const BEARER_HEADER_NAME = "x-assembly-request-bearer";
7
7
  export declare const APP_CSRF_HEADER = "x-assembly-app";
8
+ export declare const CLIENT_BEARER_HEADER = "x-assembly-client-bearer";
9
+ export declare const BEARER_IN_BODY_HEADER = "x-assembly-bearer-in-body";
10
+ export declare const COOKIE_PROBE_NAME = "assembly-cookie-probe";
11
+ export declare function serializeCookieProbe(partitioned: boolean, clear?: boolean): string;
8
12
  export declare const TOKEN_EXPIRED_HEADER = "x-assembly-token-expired";
9
13
  export declare const EXCHANGE_REJECTION_HEADER = "x-assembly-exchange-rejection";
10
14
  export type ExchangeRejectedViewer = 'same_viewer' | 'different_viewer';
11
15
  export declare function parseExchangeRejection(value: string | null): ExchangeRejectedViewer | undefined;
12
16
  export declare function isSessionExchangeEnabled(): boolean;
13
17
  export declare const COOKIE_OUTCOME_EVENT = "assembly.cookie_outcome";
14
- export type CookieOutcome = 'served_from_cookie' | 'minted' | `fail_closed:${string}`;
18
+ export type CookieOutcome = 'served_from_cookie' | 'served_from_client_bearer' | 'minted' | `fail_closed:${string}`;
15
19
  export declare function logCookieOutcome(outcome: CookieOutcome, detail?: {
16
20
  rejectedToken?: string;
17
21
  }): void;
@@ -20,6 +24,7 @@ export type RenewOutcome = 'renewed' | 'no_token' | 'rejected' | 'unavailable';
20
24
  export declare function logRenewOutcome(outcome: RenewOutcome, detail?: {
21
25
  bridgeRefreshFailed?: boolean;
22
26
  status?: number;
27
+ cookieless?: boolean;
23
28
  }): void;
24
29
  export declare function isDocumentRequest(request: {
25
30
  method: string;
@@ -47,6 +47,38 @@ export const BEARER_HEADER_NAME = 'x-assembly-request-bearer';
47
47
  // presence is sufficient: a cross-site request physically cannot carry a
48
48
  // custom header without a CORS preflight, and this app grants none.
49
49
  export const APP_CSRF_HEADER = 'x-assembly-app';
50
+ // Bearer carried by the client instead of the cookie, for browsers that refuse
51
+ // to store it: Safari blocks every cross-site-iframe cookie and does not
52
+ // implement CHIPS, so nothing the proxy sets is ever sent back. Distinct from
53
+ // BEARER_HEADER_NAME so an inbound value can never be mistaken for the one the
54
+ // proxy forwards to the render; both are stripped from every inbound request.
55
+ export const CLIENT_BEARER_HEADER = 'x-assembly-client-bearer';
56
+ // Asks the re-exchange route to return the minted bearer in its response body.
57
+ // Sent only by a client that has proven the cookie is unavailable, so a browser
58
+ // whose cookie works keeps the original response shape and never receives a
59
+ // bearer anywhere a script could read it.
60
+ export const BEARER_IN_BODY_HEADER = 'x-assembly-bearer-in-body';
61
+ // Client-readable twin of the bearer cookie: same attributes, no HttpOnly, no
62
+ // value worth having. The bearer itself is HttpOnly, so its absence is
63
+ // invisible to the client; a browser that dropped this one dropped the bearer
64
+ // beside it, which is what the client tests to detect cookie-less mode.
65
+ export const COOKIE_PROBE_NAME = 'assembly-cookie-probe';
66
+ // The probe answers "can this browser store our cookies at all", which does not
67
+ // stop being true when the bearer expires. Giving it the bearer's TTL would make
68
+ // a cookie-capable browser look cookie-less at every renewal, so it outlives the
69
+ // bearer and is cleared explicitly instead.
70
+ const COOKIE_PROBE_TTL_SECONDS = 24 * 60 * 60;
71
+ // Serialize the probe to accompany a bearer write or clear. Attributes mirror
72
+ // serializeBearerCookie (minus HttpOnly, and minus its TTL) so the two share a
73
+ // fate in any browser that refuses the cookie outright — a probe that survived
74
+ // where the bearer was refused would make the detection lie.
75
+ export function serializeCookieProbe(partitioned, clear = false) {
76
+ const maxAge = clear ? 0 : COOKIE_PROBE_TTL_SECONDS;
77
+ let cookie = `${COOKIE_PROBE_NAME}=1; Path=/; Secure; SameSite=None; Max-Age=${maxAge}`;
78
+ if (partitioned)
79
+ cookie += '; Partitioned';
80
+ return cookie;
81
+ }
50
82
  // Marker header on 401s that mean "the bearer expired — re-exchange and retry",
51
83
  // emitted by requireAuth / getAssemblyForRequest / the re-exchange route. Its
52
84
  // absence on a denial is what lets authFetch skip renewal for genuine
@@ -99,12 +131,15 @@ export function logRenewOutcome(outcome,
99
131
  // `bridgeRefreshFailed` records that the pre-renewal token refresh threw and
100
132
  // we fell back to the token already in hand — on its own that is survivable,
101
133
  // but paired with a rejection it names the parent as the failing side.
134
+ // `cookieless` marks a renewal on the header-borne path, so its success rate
135
+ // can be read separately from the cookie population's.
102
136
  detail) {
103
137
  console.log(JSON.stringify({
104
138
  event_type: RENEW_OUTCOME_EVENT,
105
139
  outcome,
106
140
  ...(detail?.bridgeRefreshFailed ? { bridge_refresh_failed: true } : {}),
107
141
  ...(typeof detail?.status === 'number' ? { status: detail.status } : {}),
142
+ ...(detail?.cookieless ? { cookieless: true } : {}),
108
143
  }));
109
144
  }
110
145
  // Two-tier detection: Sec-Fetch-Dest when the browser sends it (all evergreen
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.25-rc.2",
3
+ "version": "0.0.25-rc.4",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -1,3 +1,4 @@
1
+ <!-- !feature:app-owned-header -->
1
2
  ---
2
3
  name: assembly-app-bridge-header
3
4
  description: Use any time the app sets breadcrumbs, primary/secondary CTA, or actions menu in the header chrome. Always drive it via useAssemblyHeader — never call AssemblyBridge.header.set* from your own useEffect. The parent auto-prepends the app name as breadcrumb 0 — never include it yourself.
@@ -86,3 +87,5 @@ The Notification Center embeds the notification surface in a plain `<iframe>` **
86
87
  **This exception applies ONLY to the `/n` route.** All other surfaces (dashboard, client portal, CRM tab) MUST continue using `useAssemblyHeader`.
87
88
 
88
89
  **Common mistake: reusing a component that calls `useAssemblyHeader` on `/n`.** If you share a form or action view between the notification surface and another surface, and the component calls `useAssemblyHeader` while also rendering an inline `<Button>`, both the header CTA and the inline button appear on non-notification surfaces — a duplicate. Fix by either creating a dedicated component for `/n` that uses only inline buttons, or by accepting a prop (e.g. `useInlineActions`) that switches between the header hook and inline buttons.
90
+
91
+ <!-- /feature:app-owned-header -->
@@ -72,13 +72,20 @@ export default async function NotificationSurface({
72
72
 
73
73
  ### CTAs on the notification surface — inline buttons, NOT the header
74
74
 
75
+ <!-- !feature:app-owned-header -->
75
76
  The Notification Center embeds the notification surface (`/n`) in a plain `<iframe>` **without app bridge header support**. `useAssemblyHeader` calls on this surface are silently ignored — `primaryCta`, `secondaryCta`, and `actionsMenu` never render.
76
77
 
77
78
  **On the notification surface, render primary action buttons (Approve, Reject, Submit, Mark Complete, etc.) as inline `<Button>` elements in the page body.** This is the one exception to the portal-wide rule that CTAs must go in the header. The exception applies ONLY to the `/n` route — all other surfaces must continue using `useAssemblyHeader`.
79
+ <!-- /feature:app-owned-header -->
80
+ <!-- feature:app-owned-header -->
81
+ **On the notification surface, render primary action buttons (Approve, Reject, Submit, Mark Complete, etc.) as inline `<Button>` elements in the page body.**
82
+ <!-- /feature:app-owned-header -->
78
83
 
79
84
  Detect the notification surface by the route itself — code under `app/n/` IS the surface. For a component shared with other surfaces, pass an explicit `variant="notification"` prop from the `/n` page rather than inferring it from `payload.notificationId`: the claim is best-effort under the session-exchange flow, so a component that keys off it can silently fall back to header CTAs that never render inside the Notification Center iframe.
85
+ <!-- !feature:app-owned-header -->
80
86
 
81
87
  **Do not reuse a component that calls `useAssemblyHeader` on the notification surface.** A component that calls `useAssemblyHeader` AND renders an inline action button produces duplicate CTAs on non-notification surfaces — the header button and the inline button both appear. Either create a dedicated component for `/n` that uses only inline buttons, or accept a prop that switches between header and inline CTAs.
88
+ <!-- /feature:app-owned-header -->
82
89
 
83
90
  ## Creating a notification
84
91