@assembly-js/app-foundation 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -19,6 +19,14 @@ A patch release never changes what an app's build requires; a minor release may
19
19
  not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
20
20
  `bump: minor` change file.
21
21
 
22
+ ## 0.8.0
23
+ - [new] `useUrlState('id')` from `@assembly-js/app-foundation/client` keeps a record selection in the URL search string, so a record panel is deep-linkable without its own route.
24
+ - [new] `bun run build` runs `check:record-urls`, which fails when a record panel is opened from React state alone, with neither the helper nor a dynamic route. It only runs on apps that carry the inner-location skill.
25
+ - [guidance] `assembly-app-inner-location` says selection state lives in the URL and a record opened from a list must load cold from its URL.
26
+
27
+ ## 0.7.3
28
+ - [guidance] The build agent's auth guide is now its own section in `CLAUDE.md` instead of sitting under "Design system", and the rule for choosing between `getAssembly()` and `getInstallerAssembly()` is stated once in `assembly-route-auth` rather than repeated in the notifications, automations and internal-users skills.
29
+
22
30
  ## 0.7.2
23
31
  - [guidance] The build agent now sets `allow="autoplay; fullscreen; picture-in-picture; encrypted-media"` and `allowfullscreen` on embedded video player iframes, so Vimeo and YouTube players can go fullscreen, autoplay and use picture-in-picture inside the portal.
24
32
 
package/CLAUDE.md CHANGED
@@ -910,7 +910,7 @@ export async function GET(request: NextRequest) {
910
910
  }
911
911
  ```
912
912
 
913
- ### How auth works — read this first (it's automatic, and not what you'd guess)
913
+ ## How auth works — read this first (it's automatic, and not what you'd guess)
914
914
 
915
915
  Your app authenticates every API call with a short-lived, user-scoped **bearer token**. You never handle the raw session token and you never pass a token to anything — the foundation manages the whole lifecycle:
916
916
 
@@ -929,7 +929,7 @@ Every authenticated call your app makes is one of exactly three kinds:
929
929
  2. **Installer client** — `getInstallerAssembly()`, allowed inside a user request. Use it for staff-wide reads and writes the app is entitled to on its own, including notification helpers, app storage and connectors. It acts as the installing admin, so filter anything from it before showing it to a client. A viewer cookie does not make it refuse; `getOfflineAssembly()` does.
930
930
  3. **Offline bearer** — for background work with no user in the loop (cron, webhooks). Call `getOfflineAssembly()` (see "Offline bearer" below); it mints an app-scoped bearer from your API key server-side. Only needed outside a request; it refuses when a viewer is present.
931
931
 
932
- #### `getAssembly()` — the only auth helper you need
932
+ ### `getAssembly()` — the only auth helper you need
933
933
 
934
934
  `getAssembly` comes from `@assembly-js/app-foundation/server`, an installed dependency — import from it, never edit or reimplement it. (Its `/client` subpath is the subset a `'use client'` module may import; `/server` covers everything server-side.) Call `getAssembly()` with **no arguments** anywhere server-side (server component, server action, route handler). It reads the bearer for the current request and returns an authenticated, per-request client:
935
935
 
@@ -942,7 +942,7 @@ const items = await assembly.listItems({});
942
942
 
943
943
  `getAssembly()` is async — always `await` it. If the bearer is missing or expired it throws `TokenInvalidError`; let it propagate, and the client helpers (`useServerAction` / `authFetch`) re-exchange and retry for you. If the platform cannot be reached to verify the bearer it throws `AuthUnavailableError` (and `getSessionIdentity()` does too) — a different error on purpose: it is not an expiry, so never catch it into `null`/signed-out and never re-exchange on it; let a route return 503, and render a retry state ("Couldn't load, try again" with a retry button) wherever a server component reads identity.
944
944
 
945
- #### Identity — who is the viewer?
945
+ ### Identity — who is the viewer?
946
946
 
947
947
  Server-side, `getSessionIdentity()` returns the viewer the platform verified for the bearer (no token to pass; the check is cached briefly per server instance):
948
948
 
@@ -972,7 +972,7 @@ export default function MyWidget() {
972
972
 
973
973
  Branch your UI on identity **before** making data calls — it's faster and more reliable than catching a 403.
974
974
 
975
- #### Fetching data — server actions via `useServerAction`
975
+ ### Fetching data — server actions via `useServerAction`
976
976
 
977
977
  Put SDK calls in `'use server'` actions that take **no token** and call `getAssembly()`. From the client, run them through `useServerAction` (`app/components/use-server-action.ts`) — it manages loading/error state and **silently re-exchanges + retries once** on a 60-minute bearer expiry, so you never write refresh logic:
978
978
 
@@ -1007,7 +1007,7 @@ For an imperative one-off (button handler, mutation), use `callAction` from `use
1007
1007
  - **Actions take no token.** Never accept, read, or pass a token — `getAssembly()` resolves the bearer itself. Never touch `AssemblyBridge.sessionToken.*` in a component or copy anything into your own `useState`/`useRef`.
1008
1008
  - **Data actions must let `TokenInvalidError` propagate** — never `try/catch` it into `null`/empty. The auto-retry only fires if the action surfaces the expiry. And never hand-roll refresh/retry; `useServerAction`/`callAction` already do it.
1009
1009
 
1010
- #### Server action or route handler?
1010
+ ### Server action or route handler?
1011
1011
 
1012
1012
  A client-to-server call is a server action by default. Its argument is a TypeScript parameter, so a payload that does not match the signature fails `tsc` in your inner loop. A route handler's body is an untyped wire contract — `JSON.stringify` on one side, `request.json()` on the other — and nothing checks that the two agree until a user sends the first request, which you cannot do from the sandbox. A mismatch `tsc` would catch in an action ships silently through a route and 400s for every user.
1013
1013
 
@@ -1023,7 +1023,7 @@ export type ReplyBody = z.input<typeof replyBody>;
1023
1023
  // component: const body: ReplyBody = { parentId, text }; // tsc rejects what the route would 400
1024
1024
  ```
1025
1025
 
1026
- #### Client → server via route handlers — `authFetch`
1026
+ ### Client → server via route handlers — `authFetch`
1027
1027
 
1028
1028
  For the route-handler calls that remain, use `authFetch` from `app/components/lib-client.ts`. The bearer rides the cookie automatically; `authFetch` adds the required CSRF header and re-exchanges + retries once on a bearer expiry:
1029
1029
 
@@ -1046,7 +1046,7 @@ Every state-changing request (POST/PUT/PATCH/DELETE) **must** go through `authFe
1046
1046
 
1047
1047
  In current apps `proxy.ts` lets a request that carries no cookies skip the CSRF check, so an inbound third-party call (a webhook) needs no proxy change; an older `proxy.ts` without that rule still answers 403 to a header-less POST. Authenticate the caller inside the route (signature or shared secret), fail closed when its secret is unset, and never add an unauthenticated test endpoint.
1048
1048
 
1049
- #### Route handlers — `getAssemblyForRequest`
1049
+ ### Route handlers — `getAssemblyForRequest`
1050
1050
 
1051
1051
  ```ts
1052
1052
  // app/api/clients/[clientId]/flavor/route.ts
@@ -1076,7 +1076,7 @@ export async function PATCH(
1076
1076
 
1077
1077
  **Auth is entirely cookie-borne: no `?token=`, no `x-assembly-token` header, no token proxy, no `TokenLink`/`useWithToken`, no client-side token at all.** The middleware put the bearer in the cookie; `getAssembly()` / `getAssemblyForRequest()` read it. That's the whole story.
1078
1078
 
1079
- #### Offline bearer — background work with no user (cron, webhooks)
1079
+ ### Offline bearer — background work with no user (cron, webhooks)
1080
1080
 
1081
1081
  **Anything on a schedule — a reminder, a digest, a recurring send — must first read the `assembly-scheduled-work` skill.** The route below only ever runs if `vercel.json` declares a cron for it and the route verifies `CRON_SECRET`; the offline bearer is the auth half, the skill is the scheduling half.
1082
1082
 
@@ -1103,7 +1103,7 @@ Rules for the offline path:
1103
1103
  - **`getSessionIdentity()` returns `null`** here — the bearer is the app, not a person. Anything that needs a user id (e.g. a notification `senderId`) must be chosen explicitly (a system/admin internal user), not read from identity.
1104
1104
  - Still bearer-only: no `?token=`, no `x-assembly-token`, no X-API-Key on data calls. The app key is used solely to mint the bearer.
1105
1105
 
1106
- #### Auth guards for `[id]`-nested routes — `requireAuth` and `requireCompanyScope`
1106
+ ### Auth guards for `[id]`-nested routes — `requireAuth` and `requireCompanyScope`
1107
1107
 
1108
1108
  Two pre-built helpers guard API routes that operate on company-scoped resources:
1109
1109
 
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ // Fails the build when a record opened from a list (a Dialog, Sheet, Drawer or
3
+ // detail panel) is driven by React state alone: the host URL never changes, so a
4
+ // reload or a shared link lands on the list instead of the record. The fix is
5
+ // either `useUrlState('id')` from `@assembly-js/app-foundation/client` or a
6
+ // dynamic route for the record on the internal surface. The match is a
7
+ // heuristic on a `selected*`-style `useState` that feeds a panel-like element.
8
+ // Dependency free on purpose: it runs inside the generated app's sandbox before
9
+ // any deps beyond Node are guaranteed.
10
+ //
11
+ // Inert unless the inner-location skill was written into the app: that skill ships
12
+ // only when the deep-link flag is on, while the layout mounts <LocationReporter />
13
+ // in every build, so the reporter alone cannot tell an off-flag app apart.
14
+ // On a platform update turn (ASSEMBLY_UPDATE_TURN=1) it only warns, because an
15
+ // update never rewrites how an app navigates.
16
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
17
+ import { join, relative, sep } from 'node:path';
18
+
19
+ const ROOT = process.cwd();
20
+ const UPDATE_TURN = process.env.ASSEMBLY_UPDATE_TURN === '1';
21
+ const APP_DIR = join(ROOT, 'app');
22
+ const MANIFEST = join(ROOT, '.assembly', 'app.json');
23
+ const INNER_LOCATION_SKILL = join(ROOT, '.claude', 'skills', 'assembly-app-inner-location', 'SKILL.md');
24
+ const SOURCE_FILE = /\.(tsx|jsx)$/;
25
+ const PAGE_FILE = /^page\.(tsx|ts|jsx|js)$/;
26
+
27
+ // `const [selectedRequest, setSelectedRequest] = useState(...)`; the setter name is not trusted.
28
+ const USE_STATE = /const\s+\[\s*([A-Za-z_$][\w$]*)\s*,\s*[A-Za-z_$][\w$]*\s*\]\s*=\s*useState\b/g;
29
+ const RECORD_SELECTION = /^(?:selected|active|viewing|viewed|current|detail|open(?:ed)?)[A-Z_]/;
30
+ // Selection of something that is not a record, or that is genuinely transient.
31
+ const NOT_A_RECORD = /(?:Tab|Filter|Status|Sort|View|Option|Date|Range|Column|Mode|Step|Page|Menu)s?$|^(?:confirm|delete|remove|menu|pending)/i;
32
+ const PANEL_ELEMENT = /<\w*(?:Dialog|Sheet|Drawer|Panel|Modal|Detail)\w*[\s>/]/;
33
+ const OPT_OUT = /record-urls:\s*transient/;
34
+
35
+ function sourceFiles(dir) {
36
+ const out = [];
37
+ if (!existsSync(dir)) return out;
38
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
39
+ const full = join(dir, entry.name);
40
+ if (entry.isDirectory()) {
41
+ if (entry.name === 'node_modules' || entry.name === '.next') continue;
42
+ out.push(...sourceFiles(full));
43
+ } else if (/\.(tsx|ts|jsx|js|mjs)$/.test(entry.name)) {
44
+ out.push(full);
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ // Prefixes of the client and notification surfaces; a dynamic route there does
51
+ // not give the internal dashboard a record URL.
52
+ function nonInternalPrefixes() {
53
+ const prefixes = new Set(['/c', '/n']);
54
+ try {
55
+ const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'));
56
+ for (const key of ['client', 'notification']) {
57
+ const p = manifest?.surfaces?.[key]?.path;
58
+ if (typeof p === 'string' && p.startsWith('/') && p !== '/') prefixes.add(p.replace(/\/+$/, ''));
59
+ }
60
+ } catch {
61
+ // defaults
62
+ }
63
+ return prefixes;
64
+ }
65
+
66
+ function routeForPageFile(file) {
67
+ const segments = relative(APP_DIR, file).split(sep).slice(0, -1);
68
+ const urlSegments = segments.filter(
69
+ (seg) => !(seg.startsWith('(') && seg.endsWith(')')) && !seg.startsWith('@'),
70
+ );
71
+ return '/' + urlSegments.join('/');
72
+ }
73
+
74
+ if (!existsSync(APP_DIR)) process.exit(0);
75
+
76
+ const files = [...sourceFiles(APP_DIR), ...sourceFiles(join(ROOT, 'components'))];
77
+ const contents = new Map(files.map((f) => [f, readFileSync(f, 'utf8')]));
78
+
79
+ if (!existsSync(INNER_LOCATION_SKILL)) process.exit(0);
80
+
81
+ const prefixes = nonInternalPrefixes();
82
+ const hasInternalRecordRoute = files.some((f) => {
83
+ if (!PAGE_FILE.test(f.split(sep).pop())) return false;
84
+ const route = routeForPageFile(f);
85
+ if (!route.includes('[')) return false;
86
+ return ![...prefixes].some((p) => route === p || route.startsWith(`${p}/`));
87
+ });
88
+ if (hasInternalRecordRoute) process.exit(0);
89
+
90
+ function escapeRe(s) {
91
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
92
+ }
93
+
94
+ const violations = [];
95
+ for (const file of files) {
96
+ if (!SOURCE_FILE.test(file)) continue;
97
+ const src = contents.get(file);
98
+ if (/\buseUrlState\b/.test(src) || OPT_OUT.test(src) || !PANEL_ELEMENT.test(src)) continue;
99
+ for (const match of src.matchAll(USE_STATE)) {
100
+ const name = match[1];
101
+ if (!RECORD_SELECTION.test(name) || NOT_A_RECORD.test(name)) continue;
102
+ // Declared and read somewhere else in the file, i.e. it feeds the panel.
103
+ const uses = src.match(new RegExp(`(?<![\\w$.])${escapeRe(name)}(?![\\w$])`, 'g')) ?? [];
104
+ if (uses.length < 3) continue;
105
+ violations.push(`${relative(ROOT, file).split(sep).join('/')} (useState: ${name})`);
106
+ }
107
+ }
108
+
109
+ if (violations.length > 0 && UPDATE_TURN) {
110
+ console.warn('');
111
+ console.warn('check-record-urls: record selection held in React state (this update turn is not failed for it):');
112
+ for (const v of violations) console.warn(' - ' + v);
113
+ console.warn('');
114
+ } else if (violations.length > 0) {
115
+ console.error('');
116
+ console.error('Record selection lives only in React state — the URL never changes when a record opens:');
117
+ console.error('');
118
+ for (const v of violations) console.error(' - ' + v);
119
+ console.error('');
120
+ console.error('A reload or a shared link lands on the list, not on the record. Fix one of:');
121
+ console.error(" - Replace the useState with `const [id, setId] = useUrlState('id')` from");
122
+ console.error(" '@assembly-js/app-foundation/client' and render the record from `id`;");
123
+ console.error(' a cold load with ?id= must open it, and Back must close it.');
124
+ console.error(' - Give the record its own route (app/requests/[id]/page.tsx) and link to it.');
125
+ console.error('A genuinely transient UI (confirm dialog, menu) can add the comment');
126
+ console.error('`// record-urls: transient` to the file.');
127
+ process.exit(1);
128
+ }
@@ -57,5 +57,5 @@ export const NON_CLIENT_EXPORTS = new Set([
57
57
  'redactUrl', 'replaceChildRows', 'requireAuth', 'requireCompanyScope', 'resolveFont',
58
58
  'resolveResource', 'sendThrottled', 'serializeBearerCookie', 'serializeBearerExpiryCookie', 'serializeCookieProbe',
59
59
  'setCookielessBearer', 'slackErrorCode', 'slackFindChannelId', 'slackListChannels',
60
- 'slackPostMessage', 'slackResolveMentions', 'snippetFromRequestBody', 'verifyBearer',
60
+ 'slackPostMessage', 'slackResolveMentions', 'snippetFromRequestBody', 'useUrlState', 'verifyBearer',
61
61
  ]);
package/dist/client.d.ts CHANGED
@@ -8,3 +8,4 @@ export * from './pagination.js';
8
8
  export * from './theme.js';
9
9
  export * from './resource-resolution.js';
10
10
  export { LocationReporter } from './location-reporter.js';
11
+ export { useUrlState } from './url-state.js';
package/dist/client.js CHANGED
@@ -25,3 +25,5 @@ export * from './theme.js';
25
25
  export * from './resource-resolution.js';
26
26
  // Browser-safe: the reporter pulls in no Node built-ins, unlike ./next.
27
27
  export { LocationReporter } from './location-reporter.js';
28
+ // Keeps a record selection in the search string so the reporter mirrors it to the host URL.
29
+ export { useUrlState } from './url-state.js';
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The query string with `key` set to `value` (or removed when null), as a
3
+ * `?`-prefixed string or '' when nothing is left. Other params, including the
4
+ * host's embed-session ones, are carried through untouched.
5
+ */
6
+ export declare function withSearchParam(search: URLSearchParams, key: string, value: string | null): string;
7
+ export type SetUrlState = (value: string | null, opts?: {
8
+ replace?: boolean;
9
+ }) => void;
10
+ /**
11
+ * State that lives in the page's search string (`?id=abc`) instead of React
12
+ * state. The location reporter already mirrors the search string into the host
13
+ * URL, so a record panel driven by this hook is deep-linkable without its own
14
+ * route: a cold load with `?id=` reopens it and Back closes it.
15
+ *
16
+ * Plain Next navigation, so it behaves the same with no bridge present. The
17
+ * component must render under a Suspense boundary, as for any `useSearchParams`.
18
+ */
19
+ export declare function useUrlState(key: string): [string | null, SetUrlState];
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+ import { useCallback } from 'react';
3
+ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
4
+ /**
5
+ * The query string with `key` set to `value` (or removed when null), as a
6
+ * `?`-prefixed string or '' when nothing is left. Other params, including the
7
+ * host's embed-session ones, are carried through untouched.
8
+ */
9
+ export function withSearchParam(search, key, value) {
10
+ const next = new URLSearchParams(search);
11
+ if (value === null || value === '')
12
+ next.delete(key);
13
+ else
14
+ next.set(key, value);
15
+ const qs = next.toString();
16
+ return qs ? `?${qs}` : '';
17
+ }
18
+ /**
19
+ * State that lives in the page's search string (`?id=abc`) instead of React
20
+ * state. The location reporter already mirrors the search string into the host
21
+ * URL, so a record panel driven by this hook is deep-linkable without its own
22
+ * route: a cold load with `?id=` reopens it and Back closes it.
23
+ *
24
+ * Plain Next navigation, so it behaves the same with no bridge present. The
25
+ * component must render under a Suspense boundary, as for any `useSearchParams`.
26
+ */
27
+ export function useUrlState(key) {
28
+ const pathname = usePathname();
29
+ const router = useRouter();
30
+ const searchParams = useSearchParams();
31
+ const value = searchParams.get(key);
32
+ const setValue = useCallback((next, opts) => {
33
+ const url = pathname + withSearchParam(new URLSearchParams(searchParams.toString()), key, next);
34
+ // Push by default so Back undoes the selection.
35
+ if (opts?.replace)
36
+ router.replace(url, { scroll: false });
37
+ else
38
+ router.push(url, { scroll: false });
39
+ }, [pathname, router, searchParams, key]);
40
+ return [value, setValue];
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -11,6 +11,10 @@ The host mirrors the app's inner page in its own address bar and can open the ap
11
11
  - Mount `<LocationReporter />` once, in `app/layout.tsx`, imported from `@assembly-js/app-foundation/client`. If it is already there, leave it.
12
12
  - Never write custom `location.report` or `location.onNavigate` code, and never hand-write a `LocationSync` component. The reporter already handles surface prefixes and route changes.
13
13
 
14
+ ## Records get a URL
15
+
16
+ Keep which record is open in the URL, not in `useState`: `const [id, setId] = useUrlState('id')` from `@assembly-js/app-foundation/client` for a panel, Sheet or Dialog, or a route like `app/requests/[id]/page.tsx`. A record opened from a list must load cold from its URL and Back must close it. `bun run build` fails `check:record-urls` for a `selectedId`-style `useState` feeding a record panel; use the helper or a route to fix it.
17
+
14
18
  ## Notifications deep-link to the record, not the app root
15
19
 
16
20
  When the app sends an email or in-product notification about a specific record (a document request, a submission), the destination is set by the notification's `ctaParams` and surface, not by `linkTo`. Pass `ctaParams` that identify the record (`ctaParams: { requestId: request.id }`) and render that record from an existing inner route of this app, one that works when opened cold. Follow `assembly-notifications` for the send-and-open pattern.
@@ -271,8 +271,6 @@ Every event also lands in the workspace's audit log, where a workspace admin rea
271
271
 
272
272
  The platform runs the action by calling the record's `baseUrl`, and `requireAuth` cannot authenticate that call. `lib/assembly-actions.ts` re-exports the wrapper from the foundation package — write that re-export, don't paste a second copy of the handler.
273
273
 
274
- Use `getAssembly()` for anything shown to or done for the person using the app, and `getInstallerAssembly()` for staff-wide reads and writes the app is entitled to on its own, including notification helpers.
275
-
276
274
  ```ts
277
275
  // lib/assembly-actions.ts
278
276
  export {
@@ -18,8 +18,6 @@ Never call `assembly.getTokenPayload()` for this — see `assembly-session-token
18
18
 
19
19
  If `internalUserId` is set, the current viewer is staff. (`clientId` is set for client viewers — see `assembly-client-users` skill.)
20
20
 
21
- Use `getAssembly()` for anything shown to or done for the person using the app, and `getInstallerAssembly()` for staff-wide reads and writes the app is entitled to on its own, including notification helpers.
22
-
23
21
  ## SDK methods
24
22
 
25
23
  - `listInternalUsers({ nextToken, limit })` — paged list of all staff in the workspace
@@ -16,8 +16,6 @@ For a required condition alert (a retainer reaching 90%, going over its allowanc
16
16
 
17
17
  Every notification is three decisions: **audience** (internal users and/or clients), **channel** (in-product, email, or both), and **context on arrival** (what the recipient sees and can do). Get those right; the rest is mechanics.
18
18
 
19
- Use `getAssembly()` for anything shown to or done for the person using the app, and `getInstallerAssembly()` for staff-wide reads and writes the app is entitled to on its own, including notification helpers.
20
-
21
19
  ### Internal vs client experience
22
20
 
23
21
  The audience changes the experience and the code: