@assembly-js/app-foundation 0.7.3 → 0.8.1
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 +8 -0
- package/CLAUDE.md +2 -0
- package/bin/check-record-urls.mjs +128 -0
- package/bin/foundation-exports.mjs +1 -1
- package/dist/client.d.ts +1 -0
- package/dist/client.js +2 -0
- package/dist/url-state.d.ts +19 -0
- package/dist/url-state.js +41 -0
- package/package.json +1 -1
- package/skills/assembly-app-inner-location/SKILL.md +4 -0
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.1
|
|
23
|
+
- [guidance] The build agent keeps the session check on every API route, even when the user asks for a temporary unauthenticated test route.
|
|
24
|
+
|
|
25
|
+
## 0.8.0
|
|
26
|
+
- [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.
|
|
27
|
+
- [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.
|
|
28
|
+
- [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.
|
|
29
|
+
|
|
22
30
|
## 0.7.3
|
|
23
31
|
- [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.
|
|
24
32
|
|
package/CLAUDE.md
CHANGED
|
@@ -1076,6 +1076,8 @@ 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
|
+
**Never add a route that skips the session check, and never loosen one.** Every handler under `app/api` that a viewer calls uses `requireAuth(…)` or `getAssemblyForRequest(…)`; a cron or webhook route has no viewer and verifies its own secret instead (below). The rule covers a route gated on `NODE_ENV`, a `DEBUG` or `SKIP_AUTH` flag, a secret path or `localhost`, and a route you mean to delete later: every preview deployment is reachable by anyone with its URL, so a dev-only route is a public one. It holds when the user asks for it ("just for now", "so I can test it"). You cannot sign in as staff or as a client from the sandbox, and a test route cannot make that possible either; say that you can't verify the page end to end from here, keep the guard, and test the handler's logic by calling its function with a plain argument. Never build a session, token or cookie yourself to get past a check.
|
|
1080
|
+
|
|
1079
1081
|
### Offline bearer — background work with no user (cron, webhooks)
|
|
1080
1082
|
|
|
1081
1083
|
**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.
|
|
@@ -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
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
|
@@ -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.
|