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

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.)
@@ -18,8 +18,8 @@ let renewalInFlight = null;
18
18
  // for the freshest session token (the parent keeps it alive) and posts it to the
19
19
  // re-exchange route, which mints a new bearer and sets the HttpOnly cookie — no
20
20
  // 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.
21
+ // app code touches the raw session token, to re-mint and keep the URL aligned
22
+ // with the cookie; it is never sent to the platform API.
23
23
  export function reexchangeBearer() {
24
24
  if (renewalInFlight)
25
25
  return renewalInFlight;
@@ -30,6 +30,9 @@ export function reexchangeBearer() {
30
30
  return renewal;
31
31
  }
32
32
  async function renewBearer() {
33
+ const previousUrlToken = typeof window === 'undefined'
34
+ ? null
35
+ : new URL(window.location.href).searchParams.get('token');
33
36
  let token = AssemblyBridge.sessionToken.getCurrent()?.token ?? '';
34
37
  // Proactively refresh: we're recovering from an expiry, so avoid re-minting
35
38
  // from a session token that may itself be stale.
@@ -55,6 +58,19 @@ async function renewBearer() {
55
58
  bridgeRefreshFailed,
56
59
  status: res.status,
57
60
  });
61
+ if (res.ok && previousUrlToken !== null) {
62
+ try {
63
+ const url = new URL(window.location.href);
64
+ // Don't overwrite a new preview session opened while renewal was in flight.
65
+ if (url.searchParams.get('token') === previousUrlToken) {
66
+ url.searchParams.set('token', token);
67
+ window.history.replaceState(window.history.state, '', url.href);
68
+ }
69
+ }
70
+ catch {
71
+ // A history update failure must not invalidate the cookie just renewed.
72
+ }
73
+ }
58
74
  return res.ok;
59
75
  }
60
76
  catch {
@@ -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
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.3",
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