@ultimat3/cli 10.0.0 → 11.0.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/CLAUDE.md +71 -0
- package/package.json +28 -26
- package/src/budgets.ts +6 -0
- package/src/cmd-db-backfill.ts +14 -1
- package/src/cmd-dev.ts +3 -0
- package/src/cmd-new.ts +34 -4
- package/src/command.ts +12 -0
- package/src/dev-assets.ts +6 -0
- package/src/dev-hooks.ts +8 -0
- package/src/dev-purge.ts +8 -2
- package/src/dev-render.ts +5 -2
- package/src/dev-roles.ts +26 -2
- package/src/dispatch.ts +8 -0
- package/src/error-catalog.ts +11 -1
- package/src/error-codes.ts +10 -0
- package/src/error-contract.ts +25 -2
- package/src/error-pages.ts +79 -0
- package/src/errors.ts +25 -0
- package/src/favicon.ts +113 -0
- package/src/fix-path.ts +104 -0
- package/src/hold.ts +73 -7
- package/src/index.ts +14 -0
- package/src/live-routes.ts +181 -0
- package/src/mcp-errors.ts +5 -0
- package/src/messages.ts +6 -4
- package/src/prerender.ts +32 -3
- package/src/script-csp.ts +17 -0
- package/src/serve.ts +12 -1
- package/src/templates/admin-page.ts +11 -7
- package/src/templates/imports.ts +26 -0
- package/src/templates/route.ts +2 -2
- package/src/templates/scaffold-app.ts +18 -6
- package/src/templates/scaffold-auth.ts +151 -0
- package/src/templates/scaffold-mcp-package.ts +6 -3
- package/src/templates/scaffold-repo.ts +1 -0
- package/src/verify-checks.ts +7 -1
package/src/mcp-errors.ts
CHANGED
|
@@ -48,6 +48,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
48
48
|
X_JOB_UNKNOWN: 'x jobs ls --json',
|
|
49
49
|
X_FIX_TARGET_UNKNOWN: 'x fix boundary apps/web/site/page.tsx --json',
|
|
50
50
|
X_ERROR_FIX_INVALID: 'x verify --json # the finding names the file, the line and the fix text',
|
|
51
|
+
X_ERROR_FIX_PATH_MISSING:
|
|
52
|
+
'x verify --json # the finding names the fix line and the path it cites',
|
|
51
53
|
X_WORKSPACE_DEP_UNDECLARED:
|
|
52
54
|
'x verify --json # the package-shape finding carries the dependency line to add',
|
|
53
55
|
X_SHOT_BROWSER_MISSING: 'bun add -d puppeteer-core',
|
|
@@ -89,6 +91,9 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
89
91
|
// this fix builds an image, writes no `.x/build-stats.json`, and the next `x verify` reports the
|
|
90
92
|
// same code. Byte-identical to `checkBudgets`'s own finding, which is the other half of the pair.
|
|
91
93
|
X_BUDGET_UNMEASURED: 'x build --target static --json && x verify --json',
|
|
94
|
+
// `x routes` first, because the finding is about a ROUTE and the table names its file and its
|
|
95
|
+
// islands; the generator that fixes it takes the directory that table just printed.
|
|
96
|
+
X_LIVE_ROUTE_NO_ISLAND: 'x routes --json # then: x g island <route-dir> --at <route-dir>',
|
|
92
97
|
X_BUILD_FAILED: 'x build --json # the finding names the failing step',
|
|
93
98
|
X_BUILD_ENTRY_MISSING:
|
|
94
99
|
'x new scratch-app --dry-run --json # the file list names every entry a build needs',
|
package/src/messages.ts
CHANGED
|
@@ -135,10 +135,12 @@ const CATALOG = {
|
|
|
135
135
|
'cli.manifest.wrote': 'manifest written to {path} ({routes} routes, {actions} actions)',
|
|
136
136
|
'cli.mcp.serving': 'mcp {transport} serving {tools} tools',
|
|
137
137
|
'cli.mcp.scopes': ' scopes {scopes}',
|
|
138
|
-
// `
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
// `bin/setup` and nothing else: the scaffold ships it, `README.md` and `bin/check` both name it,
|
|
139
|
+
// and it is the only spelling that is right on a fresh clone — it installs, writes
|
|
140
|
+
// `.env.development.local`, runs `x db gen "initial"` (the scaffold writes no migration, so the
|
|
141
|
+
// drift step is red until it has), migrates and seeds. The four-command line this replaced named
|
|
142
|
+
// `x dev` off a tree where nothing had installed the CLI yet, and skipped the seed entirely.
|
|
143
|
+
'cli.new.done': 'created {name} — next: cd {name} && bin/setup && x dev',
|
|
142
144
|
// The two prose lines of `x new`'s report. The `run: cd … && git init …` line beneath the second
|
|
143
145
|
// one stays inline in `cmd-new.ts`: it is an instruction to paste verbatim, and a translated
|
|
144
146
|
// command is a broken one — the same split `Finding.fix` already makes.
|
package/src/prerender.ts
CHANGED
|
@@ -13,6 +13,8 @@ import { appManifest } from './app-manifest';
|
|
|
13
13
|
import type { RouteStats } from './budgets';
|
|
14
14
|
import { measureDocumentJs, writeBuildStats } from './budgets';
|
|
15
15
|
import { routeDocument } from './dev-render';
|
|
16
|
+
import { errorPageDocument, STATIC_ERROR_PAGE } from './error-pages';
|
|
17
|
+
import { FAVICON_PATH, faviconBytes } from './favicon';
|
|
16
18
|
import type { IslandBundle } from './island-bundle';
|
|
17
19
|
import { buildIslands, writeIslands } from './island-bundle';
|
|
18
20
|
import type { SkippedRoute, UnmeasuredRoute } from './static-report';
|
|
@@ -95,6 +97,9 @@ function heaviestSource(
|
|
|
95
97
|
|
|
96
98
|
export const DEFAULT_ORIGIN = 'https://localhost';
|
|
97
99
|
|
|
100
|
+
/** The one status a static export can answer for itself: a path that matches no file. */
|
|
101
|
+
const NOT_FOUND_STATUS = 404;
|
|
102
|
+
|
|
98
103
|
/**
|
|
99
104
|
* Prerendering and measuring are two questions, and conflating them made `X_BUDGET_UNMEASURED`
|
|
100
105
|
* unclosable by any invocation: only `static` was ever rendered, so a `budget:` on an ssr, isr,
|
|
@@ -127,6 +132,21 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
127
132
|
// behind it, so the artifact carries every byte the browser will ask for.
|
|
128
133
|
const islands = await buildIslands(options.root);
|
|
129
134
|
await writeIslands(islands, options.out);
|
|
135
|
+
// Same rule, one asset further: a browser asks for `/favicon.ico` on the first page it loads,
|
|
136
|
+
// and a static export has no route to answer it — so the bytes the served surfaces would have
|
|
137
|
+
// returned go into the artifact instead of leaving a 404 in every visitor's console.
|
|
138
|
+
await Bun.write(
|
|
139
|
+
join(options.out, FAVICON_PATH.slice(1)),
|
|
140
|
+
(await faviconBytes(options.root)).bytes,
|
|
141
|
+
);
|
|
142
|
+
// And the one error page a static host serves ITSELF: `404.html` at the export root is what S3,
|
|
143
|
+
// Cloudflare Pages, Netlify and nginx all reach for when a path matches no file. The app's own
|
|
144
|
+
// file if it wrote one, the framework's page otherwise — the same two rungs the served process
|
|
145
|
+
// answers a 404 with, so the artifact and the server cannot disagree about one document.
|
|
146
|
+
await Bun.write(
|
|
147
|
+
join(options.out, STATIC_ERROR_PAGE),
|
|
148
|
+
await errorPageDocument(options.root, NOT_FOUND_STATUS),
|
|
149
|
+
);
|
|
130
150
|
|
|
131
151
|
// Every render below goes through `routeDocument`, which is the function a REQUEST reaches — and
|
|
132
152
|
// a request arrives inside `runWithContext`, installed by the HTTP pipeline (`dev-render.ts`).
|
|
@@ -186,6 +206,13 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
186
206
|
skipped.push(skippedRoute(facts, 'no-prerender-paths'));
|
|
187
207
|
continue;
|
|
188
208
|
}
|
|
209
|
+
// One stats row per ROUTE, holding its heaviest page. `checkBudgets` looks a route up by
|
|
210
|
+
// `route.url`, which is the manifest's DECLARED pattern (`/blog/:slug`), and this pushed the
|
|
211
|
+
// FILLED path (`/blog/hello`) — so no dynamic static route has ever been weighed: every one
|
|
212
|
+
// was `X_BUDGET_UNMEASURED` and `X_BUDGET_EXCEEDED` could not fire for the whole class. The
|
|
213
|
+
// heaviest page and not the first, because a budget is a ceiling: the page that breaks it is
|
|
214
|
+
// the one the route has to answer for. `pages` below still names every filled path.
|
|
215
|
+
let heaviest: RouteStats | undefined;
|
|
189
216
|
for (const artifact of artifacts) {
|
|
190
217
|
const file = join(options.out, artifact.outputPath);
|
|
191
218
|
const bytes = await Bun.write(file, artifact.html);
|
|
@@ -200,12 +227,14 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
200
227
|
// declared budget against bytes that exist on disk rather than against a graph's estimate.
|
|
201
228
|
const measured = await measureDocumentJs(artifact.html, options.out);
|
|
202
229
|
const chain = heaviestSource(islands, measured.entries);
|
|
203
|
-
|
|
204
|
-
|
|
230
|
+
if (heaviest !== undefined && heaviest.jsBytes >= measured.jsBytes) continue;
|
|
231
|
+
heaviest = {
|
|
232
|
+
path: entry.path,
|
|
205
233
|
jsBytes: measured.jsBytes,
|
|
206
234
|
...(chain === undefined ? {} : { heaviestChain: chain }),
|
|
207
|
-
}
|
|
235
|
+
};
|
|
208
236
|
}
|
|
237
|
+
if (heaviest !== undefined) routes.push(heaviest);
|
|
209
238
|
}
|
|
210
239
|
const stats = await writeBuildStats(options.root, { routes });
|
|
211
240
|
// Written LAST and by the same call that writes the stats, so an app whose `prerender.ts` does
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Every inline `<script>` body a served process can put in a document, as the `script-src` sources
|
|
2
|
+
// that admit it. The mirror of `style-csp.ts`, and needed for the same reason: `script-src` was
|
|
3
|
+
// `'self' 'wasm-unsafe-eval'` while every document carrying an island shipped the hydration runtime
|
|
4
|
+
// INLINE, so under the enforced policy a container serves (`dev: false`) no island ever booted —
|
|
5
|
+
// invisible in `x dev`, where the policy is report-only.
|
|
6
|
+
|
|
7
|
+
import { cspHashSource } from '@ultimat3/http';
|
|
8
|
+
import { HYDRATE_RUNTIME_BODIES } from '@ultimat3/render';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Hashes, never a nonce: a `render: 'static'` page is a file on disk, so no per-response value can
|
|
12
|
+
* reach it. Read from `@ultimat3/render`'s own enumeration rather than restated here — the body
|
|
13
|
+
* the document carries and the body the policy hashes have to be one string.
|
|
14
|
+
*/
|
|
15
|
+
export function inlineScriptSources(): readonly string[] {
|
|
16
|
+
return [...new Set(HYDRATE_RUNTIME_BODIES.map(cspHashSource))].sort();
|
|
17
|
+
}
|
package/src/serve.ts
CHANGED
|
@@ -328,6 +328,9 @@ async function bootRoles(boot: {
|
|
|
328
328
|
// Same declaration `x dev` reads. Without it a container answers a browser that opened a
|
|
329
329
|
// guarded page with the problem document, rendered as raw JSON in the viewport.
|
|
330
330
|
signInPath: await loadSignInPath(options.root),
|
|
331
|
+
// The app's own `apps/web/site/errors/<status>.html`, resolved inside `startWeb` so this
|
|
332
|
+
// process and `x dev` cannot answer a browser differently.
|
|
333
|
+
root: options.root,
|
|
331
334
|
http: CONTAINER_BINDING,
|
|
332
335
|
...(options.runtime === undefined ? {} : { overrides: options.runtime }),
|
|
333
336
|
});
|
|
@@ -367,6 +370,14 @@ export async function runRole(options: ServeOptions): Promise<StartedApp> {
|
|
|
367
370
|
}
|
|
368
371
|
const app = await serveApp({ ...options, role });
|
|
369
372
|
logger.info('ultimate started', { role: app.role, url: app.url, buildId: app.buildId });
|
|
370
|
-
|
|
373
|
+
// `exit` because this is the one entry point with nothing above it: `bin.ts` ends in
|
|
374
|
+
// `process.exit(code)` and `apps/web/server.ts` — which is what awaits this — does not. One
|
|
375
|
+
// non-unref'd interval anywhere in the app then holds an event loop that has nothing left to do,
|
|
376
|
+
// until `terminationGracePeriodSeconds` runs out and the kubelet SIGKILLs a drained process.
|
|
377
|
+
await holdUntilShutdown('serve', () => app.stop(), {
|
|
378
|
+
exit: (code) => {
|
|
379
|
+
process.exit(code);
|
|
380
|
+
},
|
|
381
|
+
})();
|
|
371
382
|
return app;
|
|
372
383
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// declaration here would hand back the unguarded second way in that seam exists to close.
|
|
6
6
|
|
|
7
7
|
import { catalogJson } from './catalog-json';
|
|
8
|
+
import { sortedImports } from './imports';
|
|
8
9
|
import { catalogPath, resolveLocales } from './locales';
|
|
9
10
|
import type { GeneratedFile } from './naming';
|
|
10
11
|
import { camel, kebab, pascal } from './naming';
|
|
@@ -47,15 +48,18 @@ const catalogImport = (module: string | undefined): string =>
|
|
|
47
48
|
/**
|
|
48
49
|
* The two imports, in the order biome's organize-imports wants — which DEPENDS on the app's scope
|
|
49
50
|
* and cannot be hardcoded either way. An app catalog (`@myapp/i18n`) sorts BEFORE
|
|
50
|
-
* `@ultimat3/admin`; the fallback `@ultimat3/i18n` sorts AFTER it
|
|
51
|
-
* every generated admin page a lint error in
|
|
52
|
-
*
|
|
53
|
-
*
|
|
51
|
+
* `@ultimat3/admin`; the fallback `@ultimat3/i18n` sorts AFTER it, and `@zebra/i18n` after that.
|
|
52
|
+
* Emitting one fixed order makes every generated admin page a lint error in one of those cases.
|
|
53
|
+
*
|
|
54
|
+
* `sortedImports` is this sort, moved to `templates/imports.ts` so the four scaffold sites that
|
|
55
|
+
* had the same mix and none of the fix share it — `x new zebra` was four `organizeImports` errors
|
|
56
|
+
* on its first `x verify`.
|
|
54
57
|
*/
|
|
55
58
|
const pageImports = (module: string | undefined): string =>
|
|
56
|
-
[
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
sortedImports([
|
|
60
|
+
`import type { AdminCustomPage, AdminPageProps } from '@ultimat3/admin';`,
|
|
61
|
+
catalogImport(module),
|
|
62
|
+
]);
|
|
59
63
|
|
|
60
64
|
/** `useT()` is per render, so the component binds it in its own body. */
|
|
61
65
|
const translatorBinding = (module: string | undefined): string =>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The order Biome's `organizeImports` wants for a generated file — COMPUTED, because it depends on
|
|
2
|
+
// the app's own scope and no fixed order can be right for every app.
|
|
3
|
+
//
|
|
4
|
+
// `import { useT } from '@myapp/i18n'` sorts BEFORE `@ultimat3/render`; `@zebra/i18n` sorts after
|
|
5
|
+
// it. Every template wrote one fixed order, so `x new zebra` scaffolded four files Biome refuses
|
|
6
|
+
// (`assist/source/organizeImports`, measured: `apps/web/site/page.tsx`,
|
|
7
|
+
// `apps/web/app/dashboard/page.tsx`, `apps/admin/app/admin/page.tsx`, `packages/mcp/src/index.ts`)
|
|
8
|
+
// and the app's very first `x verify` was red on its `lint` step. `x new alpha` was clean, which is
|
|
9
|
+
// why nothing caught it: both CI fixtures — `demoapp` and `bareapp` — sort before `ultimat3`.
|
|
10
|
+
//
|
|
11
|
+
// `templates/admin-page.ts` already did this by hand for its two lines; this is that sort, in one
|
|
12
|
+
// place, for every generator that mixes an app specifier with a framework one.
|
|
13
|
+
|
|
14
|
+
/** The quoted specifier a line imports from — the only thing Biome orders these lines by. */
|
|
15
|
+
const specifierOf = (line: string): string => line.slice(line.indexOf("'"));
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One import block, sorted the way Biome would sort it.
|
|
19
|
+
*
|
|
20
|
+
* BARE specifiers only (`@myapp/i18n`, `@ultimat3/render`, `solid-js`). A relative specifier
|
|
21
|
+
* (`./page.module.scss`) belongs to a LATER group and sorts before every `@` by plain string
|
|
22
|
+
* compare, so passing one here would emit the block Biome then moves — the exact defect this
|
|
23
|
+
* exists to end. Templates write those lines after the block, where they already are.
|
|
24
|
+
*/
|
|
25
|
+
export const sortedImports = (lines: readonly string[]): string =>
|
|
26
|
+
[...lines].sort((a, b) => (specifierOf(a) < specifierOf(b) ? -1 : 1)).join('\n');
|
package/src/templates/route.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// fallback is a blank screen on a train.
|
|
6
6
|
|
|
7
7
|
import { catalogJson } from './catalog-json';
|
|
8
|
+
import { sortedImports } from './imports';
|
|
8
9
|
import { catalogPath, resolveLocales } from './locales';
|
|
9
10
|
import type { GeneratedFile } from './naming';
|
|
10
11
|
import { kebab, pascal, titleKey } from './naming';
|
|
@@ -112,8 +113,7 @@ const pageSource = (surface: Surface, path: string, module: string | undefined):
|
|
|
112
113
|
return `// Route: /${path} on the ${surface} surface. Config first: render mode, offline
|
|
113
114
|
// strategy and budget are declarations, not runtime choices.
|
|
114
115
|
|
|
115
|
-
${catalogImport(module)}
|
|
116
|
-
import { defineRoute } from '@ultimat3/render';
|
|
116
|
+
${sortedImports([catalogImport(module), `import { defineRoute } from '@ultimat3/render';`])}
|
|
117
117
|
import styles from './page.module.scss';
|
|
118
118
|
|
|
119
119
|
export const config = defineRoute({
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// already speaks MCP, and the mobile/desktop placeholders that exist so adding them later is not
|
|
3
3
|
// a restructure. Every file here is real, typed and covered — no placeholder that fails to boot.
|
|
4
4
|
|
|
5
|
+
import { sortedImports } from './imports';
|
|
5
6
|
import type { GeneratedFile, NameSet } from './naming';
|
|
6
7
|
import { apiFiles } from './scaffold-api';
|
|
8
|
+
import { authFiles } from './scaffold-auth';
|
|
7
9
|
import { entryFiles } from './scaffold-entries';
|
|
8
10
|
import { icon } from './scaffold-icon';
|
|
9
11
|
import { rolesFiles } from './scaffold-roles';
|
|
@@ -47,8 +49,10 @@ const sitePage = (
|
|
|
47
49
|
// \`t\` in @ultimat3/i18n. That import is what puts the module holding \`defineCatalogs()\` in
|
|
48
50
|
// this page's graph, so rendering a string is what registers the catalogs. A page that reached
|
|
49
51
|
// past it shipped every string as \`\u27e6key\u27e7\` with \`x verify\` green (issue #249).
|
|
50
|
-
|
|
51
|
-
import {
|
|
52
|
+
${sortedImports([
|
|
53
|
+
`import { useT } from '@${app.kebab}/i18n';`,
|
|
54
|
+
`import { defineRoute } from '@ultimat3/render';`,
|
|
55
|
+
])}
|
|
52
56
|
import styles from './page.module.scss';
|
|
53
57
|
|
|
54
58
|
export const config = defineRoute({
|
|
@@ -126,8 +130,10 @@ const dashboardPage = (
|
|
|
126
130
|
// as their data resolves.
|
|
127
131
|
|
|
128
132
|
// \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
|
|
129
|
-
|
|
130
|
-
import {
|
|
133
|
+
${sortedImports([
|
|
134
|
+
`import { useT } from '@${app.kebab}/i18n';`,
|
|
135
|
+
`import { defineRoute } from '@ultimat3/render';`,
|
|
136
|
+
])}
|
|
131
137
|
import styles from './page.module.scss';
|
|
132
138
|
|
|
133
139
|
export const config = defineRoute({
|
|
@@ -323,8 +329,10 @@ const adminPage = (
|
|
|
323
329
|
// user's agents can drive the user's product with the user's permissions.
|
|
324
330
|
|
|
325
331
|
// \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
|
|
326
|
-
|
|
327
|
-
import {
|
|
332
|
+
${sortedImports([
|
|
333
|
+
`import { useT } from '@${app.kebab}/i18n';`,
|
|
334
|
+
`import { defineRoute } from '@ultimat3/render';`,
|
|
335
|
+
])}
|
|
328
336
|
|
|
329
337
|
export const config = defineRoute({
|
|
330
338
|
render: 'ssr',
|
|
@@ -377,6 +385,10 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
|
|
|
377
385
|
{ path: 'apps/web/app/dashboard/page.tsx', contents: dashboardPage(app) },
|
|
378
386
|
{ path: 'apps/web/app/dashboard/page.module.scss', contents: dashboardStyle() },
|
|
379
387
|
{ path: 'apps/web/app/dashboard/page.test.ts', contents: dashboardTest() },
|
|
388
|
+
// The third piece of the authz story the scaffold already tells twice: the routes declare a
|
|
389
|
+
// policy and `shared/roles.ts` declares the grants, and until this file existed nothing
|
|
390
|
+
// answered "who is this?" — so every one of those routes refused every request.
|
|
391
|
+
...authFiles(app),
|
|
380
392
|
{ path: 'apps/web/app/offline.tsx', contents: offlineFallback(app) },
|
|
381
393
|
{ path: 'apps/web/app/offline.module.scss', contents: offlineStyle() },
|
|
382
394
|
// The third surface, and the one call that registers what the app declares — `scaffold-api.ts`.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// The scaffold's answer to "who is this?", which it did not have.
|
|
2
|
+
//
|
|
3
|
+
// `hooks.authenticate` is the ONLY place an actor can come from, and nothing in a generated app
|
|
4
|
+
// called `configureAuthenticator()` — so a fresh `x new` booted with
|
|
5
|
+
// `X_CONFIG_INVALID: 7 route(s) declare auth: 'required' and no authenticator is configured` on
|
|
6
|
+
// every start, and its own `/dashboard` answered 401 on the first click. The scaffold declares the
|
|
7
|
+
// routes and the roles; this is the missing third piece, and it is deliberately the smallest one
|
|
8
|
+
// that can be honest: a viewer named by a cookie, installed in `development` and nowhere else.
|
|
9
|
+
//
|
|
10
|
+
// The alternative — dropping `policy:` from the scaffolded routes — was refused: a dashboard that
|
|
11
|
+
// declares no policy is registered `auth: 'public'`, which also skips `render-ssr`'s gated branch,
|
|
12
|
+
// so the document ships with no `vary: cookie` and a shared cache may hand one visitor's page to
|
|
13
|
+
// the next. The scaffold would teach the wrong shape to every app that starts from it.
|
|
14
|
+
|
|
15
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
16
|
+
|
|
17
|
+
const devActor = (
|
|
18
|
+
app: NameSet,
|
|
19
|
+
): string => `// Who a browser is until this app issues sessions of its own.
|
|
20
|
+
//
|
|
21
|
+
// \`hooks.authenticate\` is the one place an actor can come from. Without it every request is
|
|
22
|
+
// anonymous, so each route declaring a \`policy:\` answers 401 and the boot warns
|
|
23
|
+
// \`X_CONFIG_INVALID\` — which is what a scaffolded app did on its very first \`x dev\`.
|
|
24
|
+
//
|
|
25
|
+
// DEVELOPMENT ONLY, and the guard is the point: a viewer that followed this to staging would sign
|
|
26
|
+
// every visitor in as an admin. \`bun test\` sets \`NODE_ENV=test\`, so it does not install there
|
|
27
|
+
// either — a fixture mints its own actor, and a second one arriving from a cookie would decide
|
|
28
|
+
// which actor a test is about.
|
|
29
|
+
//
|
|
30
|
+
// REPLACE IT with the real thing: resolve a session cookie to a row, and return that actor.
|
|
31
|
+
// Everything downstream — pages, policies, live subscribers, MCP tools — reads what this returns.
|
|
32
|
+
import { type Actor, logger, tryResolveEnvironment } from '@ultimat3/core';
|
|
33
|
+
import { configureAuthenticator, readCookie } from '@ultimat3/http';
|
|
34
|
+
|
|
35
|
+
/** Set it to a role from \`apps/web/shared/roles.ts\` to browse as that role. */
|
|
36
|
+
export const DEV_ROLE_COOKIE = '${app.kebab}_dev_role';
|
|
37
|
+
|
|
38
|
+
/** The roles \`shared/roles.ts\` declares. A cookie naming anything else falls back. */
|
|
39
|
+
export const DEV_ROLES = ['member', 'admin'] as const;
|
|
40
|
+
|
|
41
|
+
export type DevRole = (typeof DEV_ROLES)[number];
|
|
42
|
+
|
|
43
|
+
/** The one that can open every scaffolded route, including \`/admin\`. */
|
|
44
|
+
export const DEFAULT_DEV_ROLE: DevRole = 'admin';
|
|
45
|
+
|
|
46
|
+
const isDevRole = (value: string | null): value is DevRole =>
|
|
47
|
+
value !== null && (DEV_ROLES as readonly string[]).includes(value);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* An unknown cookie value falls back rather than refusing: the cookie is a viewing convenience,
|
|
51
|
+
* and a typo that resolved nobody would reproduce the 401 this module exists to remove.
|
|
52
|
+
*/
|
|
53
|
+
export const devRoleFrom = (cookieHeader: string | null): DevRole => {
|
|
54
|
+
const named = readCookie(cookieHeader, DEV_ROLE_COOKIE);
|
|
55
|
+
return isDevRole(named) ? named : DEFAULT_DEV_ROLE;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* \`roles\`, never a permission list: \`can()\` expands the role map at decision time, so a grant
|
|
60
|
+
* moved between roles reaches this actor without an edit here.
|
|
61
|
+
*/
|
|
62
|
+
export const devActorFor = (role: DevRole): Actor => ({
|
|
63
|
+
kind: 'user',
|
|
64
|
+
id: 'dev-actor',
|
|
65
|
+
orgId: 'dev-org',
|
|
66
|
+
roles: [role],
|
|
67
|
+
// Both required, and both deliberately empty: \`scopes\` is the framework's own escape hatch
|
|
68
|
+
// (\`tenancy:cross\`) and \`permissions\` is a DIRECT grant that bypasses the role map — a
|
|
69
|
+
// development viewer holds exactly what its role holds, and nothing a rule cannot explain.
|
|
70
|
+
scopes: [],
|
|
71
|
+
permissions: [],
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Installs it, and says so — loudly, because a silent stand-in for authentication is the one thing
|
|
76
|
+
* worse than none. Returns whether it installed, so the test can assert both halves.
|
|
77
|
+
*/
|
|
78
|
+
export function installDevAuthenticator(
|
|
79
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
80
|
+
): boolean {
|
|
81
|
+
if (tryResolveEnvironment({ env }) !== 'development') return false;
|
|
82
|
+
configureAuthenticator((request) => devActorFor(devRoleFrom(request.header('cookie'))));
|
|
83
|
+
logger.warn('every request is answered as a development viewer', {
|
|
84
|
+
role: DEFAULT_DEV_ROLE,
|
|
85
|
+
cause:
|
|
86
|
+
'apps/web/app/auth/dev-actor.ts installs a viewer in development only, because this app issues no session yet',
|
|
87
|
+
fix: \`browse as someone else: document.cookie = '\${DEV_ROLE_COOKIE}=member'\`,
|
|
88
|
+
});
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Module scope, which IS the wiring: the boot scan imports every module under \`apps/*\` before a
|
|
93
|
+
// listener binds, and \`x dev\` and the container both read the configured value back at start.
|
|
94
|
+
installDevAuthenticator();
|
|
95
|
+
`;
|
|
96
|
+
|
|
97
|
+
const devActorTest =
|
|
98
|
+
(): string => `// The two halves that make a development-only stand-in safe: it resolves the cookie, and it does
|
|
99
|
+
// not install itself anywhere but development.
|
|
100
|
+
import { configuredAuthenticator, resetAuthenticator } from '@ultimat3/http';
|
|
101
|
+
import { actorHas } from '@ultimat3/policy';
|
|
102
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
103
|
+
import { roles } from '../../shared/roles';
|
|
104
|
+
import {
|
|
105
|
+
DEFAULT_DEV_ROLE,
|
|
106
|
+
DEV_ROLE_COOKIE,
|
|
107
|
+
devActorFor,
|
|
108
|
+
devRoleFrom,
|
|
109
|
+
installDevAuthenticator,
|
|
110
|
+
} from './dev-actor';
|
|
111
|
+
|
|
112
|
+
unitTest('the cookie names the role, and anything else falls back', () => {
|
|
113
|
+
expect(devRoleFrom(\`\${DEV_ROLE_COOKIE}=member\`)).toBe('member');
|
|
114
|
+
expect(devRoleFrom(\`\${DEV_ROLE_COOKIE}=nobody\`)).toBe(DEFAULT_DEV_ROLE);
|
|
115
|
+
expect(devRoleFrom(null)).toBe(DEFAULT_DEV_ROLE);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
unitTest('the actor it mints holds what the role map grants it, and nothing else', () => {
|
|
119
|
+
// \`actorHas\` and not \`holds\`: this is the function \`can()\` itself calls, so the assertion is
|
|
120
|
+
// the pipeline's own decision rather than a second implementation of it. The map is passed
|
|
121
|
+
// explicitly — a test that depended on which module imported first would pass alone and fail
|
|
122
|
+
// inside a suite.
|
|
123
|
+
expect(actorHas(devActorFor('admin'), 'admin:read', roles)).toBe(true);
|
|
124
|
+
expect(actorHas(devActorFor('member'), 'dashboard:read', roles)).toBe(true);
|
|
125
|
+
// The whole reason this is development-only: a member is not an admin, and neither is a deploy.
|
|
126
|
+
expect(actorHas(devActorFor('member'), 'admin:read', roles)).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
unitTest('it installs in development and in no other environment', () => {
|
|
130
|
+
// This process is \`test\`, so the module-scope call at the bottom of dev-actor.ts installed
|
|
131
|
+
// nothing — which is what keeps a fixture's own actor the only one a test can be about.
|
|
132
|
+
expect(configuredAuthenticator()).toBeUndefined();
|
|
133
|
+
|
|
134
|
+
expect(installDevAuthenticator({ ULTIMATE_ENV: 'production' })).toBe(false);
|
|
135
|
+
expect(installDevAuthenticator({ ULTIMATE_ENV: 'staging' })).toBe(false);
|
|
136
|
+
expect(configuredAuthenticator()).toBeUndefined();
|
|
137
|
+
|
|
138
|
+
expect(installDevAuthenticator({ ULTIMATE_ENV: 'development' })).toBe(true);
|
|
139
|
+
expect(configuredAuthenticator()).toBeDefined();
|
|
140
|
+
// Process-global, so the case that installed one takes it back out.
|
|
141
|
+
resetAuthenticator();
|
|
142
|
+
});
|
|
143
|
+
`;
|
|
144
|
+
|
|
145
|
+
/** The app's development viewer, beside the roles it names. */
|
|
146
|
+
export function authFiles(app: NameSet): readonly GeneratedFile[] {
|
|
147
|
+
return [
|
|
148
|
+
{ path: 'apps/web/app/auth/dev-actor.ts', contents: devActor(app) },
|
|
149
|
+
{ path: 'apps/web/app/auth/dev-actor.test.ts', contents: devActorTest() },
|
|
150
|
+
];
|
|
151
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// action registry rather than re-listed, plus the test that pins every projected tool describing
|
|
3
3
|
// itself.
|
|
4
4
|
|
|
5
|
+
import { sortedImports } from './imports';
|
|
5
6
|
import type { GeneratedFile, NameSet } from './naming';
|
|
6
7
|
import { packageShapeFiles } from './scaffold-package-shape';
|
|
7
8
|
|
|
@@ -44,9 +45,11 @@ const mcpIndex = (
|
|
|
44
45
|
app: NameSet,
|
|
45
46
|
): string => `// The app's own MCP tools. Every action with mcp.expose is already a tool; add app-specific
|
|
46
47
|
// read-only helpers here. Authorization is the action's policy, unchanged.
|
|
47
|
-
|
|
48
|
-
import
|
|
49
|
-
import {
|
|
48
|
+
${sortedImports([
|
|
49
|
+
`import * as api from '@${app.kebab}/web/api/health';`,
|
|
50
|
+
`import { registerActions } from '@ultimat3/action';`,
|
|
51
|
+
`import { defineAppMcp } from '@ultimat3/mcp';`,
|
|
52
|
+
])}
|
|
50
53
|
|
|
51
54
|
// Names come from export names, so the registry agrees with the module the app already wrote.
|
|
52
55
|
registerActions(api);
|
|
@@ -63,6 +63,7 @@ const rootPackage = (app: NameSet, version: string): string => `{
|
|
|
63
63
|
"@ultimat3/core": "^${version}",
|
|
64
64
|
"@ultimat3/db": "^${version}",
|
|
65
65
|
"@ultimat3/entity": "^${version}",
|
|
66
|
+
"@ultimat3/http": "^${version}",
|
|
66
67
|
"@ultimat3/i18n": "^${version}",
|
|
67
68
|
"@ultimat3/jobs": "^${version}",
|
|
68
69
|
"@ultimat3/mcp": "^${version}",
|
package/src/verify-checks.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { checkSourceDrift } from './drift';
|
|
|
27
27
|
import { checkErrorFixReport } from './error-contract';
|
|
28
28
|
import { guardFindings } from './guards';
|
|
29
29
|
import { catalogFindings } from './i18n-registration';
|
|
30
|
+
import { liveRouteFindings } from './live-routes';
|
|
30
31
|
import { msg } from './messages';
|
|
31
32
|
import type { Finding } from './output';
|
|
32
33
|
import { findingFrom } from './output';
|
|
@@ -171,7 +172,8 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
171
172
|
},
|
|
172
173
|
{
|
|
173
174
|
name: 'budgets',
|
|
174
|
-
summary:
|
|
175
|
+
summary:
|
|
176
|
+
'per-route JS bytes and LCP, the global style layer every document carries, and the routes that boot nothing to receive their live rows',
|
|
175
177
|
// The global-style assertion rides here rather than becoming an eighteenth step, because this
|
|
176
178
|
// step already asks the one question it asks: what does the document this build emits actually
|
|
177
179
|
// contain? It is also the same app load — `appManifest` fills render's stylesheet registry on
|
|
@@ -202,6 +204,10 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
202
204
|
return fromFindings([
|
|
203
205
|
...findings,
|
|
204
206
|
...checkDocumentStyles(documentSurfaces()),
|
|
207
|
+
// The third rider, and the same question this step already asks one level down: what
|
|
208
|
+
// JavaScript does this route's document boot? A live read with no island is a route
|
|
209
|
+
// whose answer is "none", which no suite can fail on — the page renders, at 200.
|
|
210
|
+
...(await liveRouteFindings(ctx.root)),
|
|
205
211
|
...checkBudgets(manifest, stats),
|
|
206
212
|
]);
|
|
207
213
|
},
|