@ultimat3/cli 22.15.0 → 23.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.
Files changed (169) hide show
  1. package/CLAUDE.md +23 -8
  2. package/README.md +28 -5
  3. package/package.json +29 -28
  4. package/src/admin-catalog-keys.ts +16 -0
  5. package/src/admin-registration.ts +192 -0
  6. package/src/api-registration.ts +36 -11
  7. package/src/app-boundaries.ts +6 -20
  8. package/src/app-load.ts +55 -11
  9. package/src/app-manifest.ts +14 -0
  10. package/src/app-permissions.ts +0 -0
  11. package/src/app-reload-graph.ts +7 -30
  12. package/src/app-transport.ts +219 -0
  13. package/src/boundary-findings.ts +16 -4
  14. package/src/browser-transport.ts +160 -0
  15. package/src/budgets.ts +25 -1
  16. package/src/cmd-build-spec.ts +10 -3
  17. package/src/cmd-build.ts +56 -9
  18. package/src/cmd-db.ts +60 -11
  19. package/src/cmd-doctor.ts +35 -11
  20. package/src/cmd-generate.ts +118 -17
  21. package/src/cmd-jobs-spec.ts +19 -3
  22. package/src/cmd-jobs.ts +120 -3
  23. package/src/cmd-new.ts +15 -1
  24. package/src/cmd-routes.ts +15 -1
  25. package/src/cmd-secrets-ring.ts +76 -0
  26. package/src/cmd-secrets-spec.ts +10 -2
  27. package/src/cmd-secrets.ts +48 -12
  28. package/src/cmd-tasks.ts +84 -15
  29. package/src/cmd-verify.ts +10 -2
  30. package/src/comment-safe.ts +16 -0
  31. package/src/coverage-floor.ts +334 -0
  32. package/src/coverage-lcov.ts +193 -0
  33. package/src/coverage-source.ts +136 -0
  34. package/src/db-object-drift.ts +30 -0
  35. package/src/db-replay-engine.ts +115 -0
  36. package/src/db-schema-dump.ts +150 -0
  37. package/src/db-schema-refresh.ts +122 -0
  38. package/src/dev-dashboard.ts +2 -2
  39. package/src/dev-route-table.ts +9 -0
  40. package/src/doctor-coverage.ts +39 -0
  41. package/src/doctor-guards.ts +54 -0
  42. package/src/doctor-sealed.ts +132 -0
  43. package/src/document-graph.ts +82 -0
  44. package/src/error-codes.ts +32 -0
  45. package/src/framework-schema.ts +15 -0
  46. package/src/generate-files.ts +76 -10
  47. package/src/generate-grant-findings.ts +76 -0
  48. package/src/generate-grants.ts +77 -3
  49. package/src/generate-kinds.ts +19 -0
  50. package/src/generate-write.ts +15 -27
  51. package/src/generated-imports.ts +59 -0
  52. package/src/guard-sources.ts +104 -0
  53. package/src/guards.ts +23 -7
  54. package/src/handle-registration.ts +217 -0
  55. package/src/i18n-registration.ts +36 -0
  56. package/src/image-prepare.ts +51 -8
  57. package/src/import-closure.ts +165 -0
  58. package/src/index.ts +11 -2
  59. package/src/island-store.ts +24 -16
  60. package/src/jobs-drain.ts +14 -7
  61. package/src/jobs-json.ts +50 -0
  62. package/src/jobs-report.ts +3 -0
  63. package/src/json-merge.ts +46 -0
  64. package/src/mcp-errors.ts +29 -1
  65. package/src/messages.ts +33 -0
  66. package/src/metrics-endpoint.ts +71 -24
  67. package/src/migration-extensions.ts +29 -0
  68. package/src/module-imports.ts +47 -0
  69. package/src/output.ts +18 -9
  70. package/src/permission-grants.ts +41 -9
  71. package/src/publish-closure.ts +101 -0
  72. package/src/role-load.ts +143 -0
  73. package/src/role-start.ts +18 -1
  74. package/src/role-wake.ts +45 -0
  75. package/src/runtime-admin.ts +141 -0
  76. package/src/runtime-hooks.ts +5 -2
  77. package/src/runtime-queue.ts +8 -1
  78. package/src/runtime-render.ts +6 -0
  79. package/src/runtime-services.ts +2 -1
  80. package/src/scaffold-typecheck.ts +1 -0
  81. package/src/schema-dump-drift.ts +64 -0
  82. package/src/serve-boot.ts +85 -157
  83. package/src/serve-prebuilt-paths.ts +19 -0
  84. package/src/serve-prebuilt.ts +84 -0
  85. package/src/serve-web.ts +164 -0
  86. package/src/serve.ts +6 -9
  87. package/src/server-barrels.ts +54 -0
  88. package/src/source-edit.ts +49 -0
  89. package/src/tasks-facts.ts +4 -1
  90. package/src/templates/action.ts +144 -11
  91. package/src/templates/admin-catalog.ts +58 -0
  92. package/src/templates/admin-page.ts +36 -8
  93. package/src/templates/admin.ts +57 -17
  94. package/src/templates/backfill.ts +121 -87
  95. package/src/templates/entity.ts +140 -44
  96. package/src/templates/guard-animated-layout-property.ts +4 -11
  97. package/src/templates/guard-bare-error.ts +9 -13
  98. package/src/templates/guard-focus-visible.ts +4 -11
  99. package/src/templates/guard-image-dimensions.ts +6 -6
  100. package/src/templates/guard-island-without-states.ts +2 -4
  101. package/src/templates/guard-raw-breakpoint.ts +210 -0
  102. package/src/templates/guard-raw-colour.ts +4 -11
  103. package/src/templates/guard-raw-length.ts +375 -0
  104. package/src/templates/guard-raw-motion.ts +213 -0
  105. package/src/templates/guard-raw-shadow.ts +187 -0
  106. package/src/templates/guard-raw-z-index.ts +166 -0
  107. package/src/templates/guard-repo-raw-sql.ts +345 -0
  108. package/src/templates/guard-semantic-interactive.ts +6 -6
  109. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  110. package/src/templates/guard-undefined-style-class.ts +215 -0
  111. package/src/templates/guard-untranslated-string.ts +6 -6
  112. package/src/templates/guard-unzoned-date.ts +9 -13
  113. package/src/templates/index.ts +3 -2
  114. package/src/templates/island.ts +34 -51
  115. package/src/templates/job-body-test.ts +121 -0
  116. package/src/templates/job.ts +23 -8
  117. package/src/templates/policy.ts +16 -9
  118. package/src/templates/query.ts +242 -26
  119. package/src/templates/resource-create.ts +62 -18
  120. package/src/templates/resource-form-island.ts +58 -80
  121. package/src/templates/resource-page.ts +269 -0
  122. package/src/templates/resource-service.ts +96 -0
  123. package/src/templates/resource.ts +86 -61
  124. package/src/templates/route.ts +38 -29
  125. package/src/templates/scaffold-api.ts +55 -1
  126. package/src/templates/scaffold-app.ts +171 -51
  127. package/src/templates/scaffold-auth.ts +83 -19
  128. package/src/templates/scaffold-browser-client.ts +90 -0
  129. package/src/templates/scaffold-container.ts +16 -0
  130. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  131. package/src/templates/scaffold-dashboard-example.ts +124 -31
  132. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  133. package/src/templates/scaffold-dashboard.ts +1 -2
  134. package/src/templates/scaffold-db-client.ts +142 -0
  135. package/src/templates/scaffold-db-package.ts +90 -8
  136. package/src/templates/scaffold-docs.ts +50 -5
  137. package/src/templates/scaffold-guards.ts +54 -11
  138. package/src/templates/scaffold-http.ts +9 -7
  139. package/src/templates/scaffold-repo.ts +52 -2
  140. package/src/templates/scaffold-roles.ts +56 -9
  141. package/src/templates/scaffold-shell.ts +36 -70
  142. package/src/templates/scaffold-site.ts +45 -19
  143. package/src/templates/scaffold-ui-package.ts +18 -0
  144. package/src/templates/slice-foundation.ts +46 -8
  145. package/src/test-counts.ts +18 -2
  146. package/src/transport-calls.ts +105 -0
  147. package/src/unscanned-admin.ts +54 -0
  148. package/src/verify-checks.ts +26 -7
  149. package/src/verify-coverage-run.ts +190 -0
  150. package/src/verify-deadline.ts +256 -0
  151. package/src/verify-errors.ts +15 -6
  152. package/src/verify-floor.ts +36 -9
  153. package/src/verify-merge.ts +117 -24
  154. package/src/verify-progress.ts +25 -0
  155. package/src/verify-role-load-probe.ts +54 -0
  156. package/src/verify-role-load.ts +148 -0
  157. package/src/verify-run.ts +93 -35
  158. package/src/verify-shard.ts +20 -9
  159. package/src/verify-step.ts +42 -1
  160. package/src/verify-tests.ts +78 -6
  161. package/src/workspace-checks.ts +50 -8
  162. package/src/workspace-dep-edit.ts +127 -0
  163. package/src/cmd-dev-fixture.ts +0 -204
  164. package/src/dev-live-fixture.ts +0 -122
  165. package/src/policy-fixture.ts +0 -78
  166. package/src/realtime-browser-probe-fixture.ts +0 -9
  167. package/src/role-start-fixture.ts +0 -78
  168. package/src/scaffold-fixture.ts +0 -160
  169. package/src/templates/island-fixture.ts +0 -76
@@ -20,7 +20,13 @@ import { siteFiles } from './scaffold-site';
20
20
  // strings through `@<app>/i18n`'s `useT()`, so the surface that renders a string DEPENDS on the
21
21
  // module that registers the catalogs. An undeclared workspace dependency resolves through the root
22
22
  // symlink and then breaks the day the app is built anywhere else.
23
- const webPackage = (app: NameSet): string => `{
23
+ //
24
+ // `@<app>/db` joins it with the example slice, whose repo.ts reads through the typed handle that
25
+ // package exports. Under `--no-example` nothing here imports it yet, so it is not declared — the
26
+ // first `x g entity` writes the line with the repo that needs it (`handle-registration.ts`).
27
+ const webPackage = (app: NameSet, example: boolean): string => {
28
+ const db = example ? `\n "@${app.kebab}/db": "0.0.0",` : '';
29
+ return `{
24
30
  "name": "@${app.kebab}/web",
25
31
  "version": "0.0.0",
26
32
  "private": true,
@@ -32,11 +38,12 @@ const webPackage = (app: NameSet): string => `{
32
38
  "scripts": {
33
39
  "typecheck": "tsc --noEmit -p tsconfig.json"
34
40
  },
35
- "dependencies": {
41
+ "dependencies": {${db}
36
42
  "@${app.kebab}/i18n": "0.0.0"
37
43
  }
38
44
  }
39
45
  `;
46
+ };
40
47
 
41
48
  // The ambient \`*.module.scss\` declaration is not reachable through an import, so a program that
42
49
  // only sees this app's files would report TS2307 on every stylesheet. Naming it in \`include\`
@@ -47,27 +54,39 @@ const tsconfig = (): string => `{
47
54
  }
48
55
  `;
49
56
 
50
- const offlineTest =
51
- (): string => `// The offline fallback has to render with nothing: no network, no session, no database, and no
57
+ const offlineTest = (
58
+ app: NameSet,
59
+ ): string => `// The offline fallback has to render with nothing: no network, no session, no database, and no
52
60
  // JavaScript. Every one of those is a config field here, and every one of them rots the moment
53
61
  // someone adds an import or a policy — at which point the page the service worker precaches is a
54
62
  // page that cannot render when it is finally needed.
55
- import { metaContextFor, routeDataFor } from '@ultimat3/render';
56
- import { expect, unitTest } from '@ultimat3/testing';
57
- import { config } from './page';
63
+ ${sortedImports([
64
+ `import { useT } from '@${app.kebab}/i18n';`,
65
+ "import { expect, renderRoute, unitTest } from '@ultimat3/testing';",
66
+ ])}
67
+ import * as page from './page';
58
68
 
59
- const ctx = { params: {}, url: 'https://example.test/offline' };
69
+ const url = 'https://example.test/offline';
70
+
71
+ unitTest('the offline fallback renders with no actor, and says the network is gone', async () => {
72
+ const t = useT();
73
+ // No actor and no context: exactly what the service worker has when it serves this.
74
+ const view = await renderRoute(page, { url });
75
+ expect(view.html.match(/<h1\\b/g)).toHaveLength(1);
76
+ expect(view.text).toBe(\`\${t('app.offline.title')} \${t('app.offline.description')}\`);
77
+ expect(view.islands).toEqual([]);
78
+ });
60
79
 
61
80
  unitTest('the offline fallback is static, precached, and ships no JavaScript', async () => {
62
- expect(config.render).toBe('static');
81
+ const view = await renderRoute(page, { url });
82
+ expect(page.config.render).toBe('static');
63
83
  // 'precache', or the document that answers a lost network is itself fetched over the network.
64
- expect(config.offline).toBe('precache');
65
- expect(config.hydrate).toBe('never');
66
- expect(config.budget.js).toBe('0kb');
84
+ expect(page.config.offline).toBe('precache');
85
+ expect(page.config.hydrate).toBe('never');
86
+ expect(page.config.budget.js).toBe('0kb');
67
87
  // A cached error page has nothing to index, and an indexed one outranks the page it stood in for
68
88
  // on the day the crawler happened to be offline.
69
- const meta = await config.meta(metaContextFor(ctx, await routeDataFor(config, ctx)));
70
- expect(meta.robots?.index).toBe(false);
89
+ expect(view.meta.robots?.index).toBe(false);
71
90
  });
72
91
  `;
73
92
 
@@ -103,7 +122,7 @@ export const config = defineRoute({
103
122
  }),
104
123
  });
105
124
 
106
- export function OfflinePage() {
125
+ export function Page() {
107
126
  const t = useT();
108
127
 
109
128
  return (
@@ -167,6 +186,25 @@ const sharedGlobalModule =
167
186
  import './global.scss';
168
187
  `;
169
188
 
189
+ const sharedGlobalTest =
190
+ (): string => `// The global layer reaches this app's module graph through exactly one edge. Cut it and every
191
+ // \`var(--…)\` a component emits resolves to nothing: the app renders unstyled, with every test of
192
+ // every component still green.
193
+ import { registeredStylesheets } from '@ultimat3/render/server';
194
+ import { expect, unitTest } from '@ultimat3/testing';
195
+ import './global';
196
+
197
+ unitTest('importing shared/global registers the one global stylesheet', () => {
198
+ const sheets = registeredStylesheets().filter((sheet) => sheet.global);
199
+ expect(sheets.map((sheet) => sheet.file.split('/').slice(-3).join('/'))).toEqual([
200
+ 'web/shared/global.scss',
201
+ ]);
202
+ // The custom properties themselves, declared once on the root — what \`tokens.role()\` refers to.
203
+ expect(sheets[0]?.css).toContain(':root{');
204
+ expect(sheets[0]?.css).toContain('--color-bg:');
205
+ });
206
+ `;
207
+
170
208
  const sharedActor =
171
209
  (): string => `// The actor type both surfaces agree on. Policies read this and nothing else, so authz cannot
172
210
  // disagree between HTTP, live queries, jobs and MCP.
@@ -208,8 +246,10 @@ unitTest('holds answers from the role map, and an anonymous actor holds nothing'
208
246
  });
209
247
  `;
210
248
 
211
- // Same one dependency as `apps/web`, and for the same reason: `app/admin/page.tsx` reads its
212
- // strings through `@<app>/i18n`'s `useT()`.
249
+ // Two dependencies, and they are the edges this workspace has: `app/admin/admin.ts` reads the
250
+ // app's typed handle from `@<app>/db`, and its test loads the role map from `@<app>/web`.
251
+ // Undeclared, they exist only inside the root tsconfig's `paths`, where `bun --filter` and every
252
+ // change-detection tool are blind to them.
213
253
  const adminPackage = (app: NameSet): string => `{
214
254
  "name": "@${app.kebab}/admin",
215
255
  "version": "0.0.0",
@@ -223,44 +263,122 @@ const adminPackage = (app: NameSet): string => `{
223
263
  "typecheck": "tsc --noEmit -p tsconfig.json"
224
264
  },
225
265
  "dependencies": {
226
- "@${app.kebab}/i18n": "0.0.0"
266
+ "@${app.kebab}/db": "0.0.0",
267
+ "@${app.kebab}/web": "0.0.0"
227
268
  }
228
269
  }
229
270
  `;
230
271
 
231
- const adminPage = (
272
+ const adminDeclaration = (
232
273
  app: NameSet,
233
- ): string => `// The generated admin dashboard. It ships an MCP surface over the app's own actions, so the
234
- // user's agents can drive the user's product with the user's permissions.
274
+ ): string => `// The whole admin dashboard. \`defineAdmin()\` serves a list, a detail and a form for every entity
275
+ // below at /admin/<entity> — the columns, filters, validation and labels are derived from the
276
+ // entity, the rows are read through the app's own typed handle, and \`x dev\` mounts the screens.
277
+ // There is no page file, no repo adapter and no screen glue to write.
278
+ //
279
+ // It also ships an MCP surface over the same resources, so the user's agents can drive the user's
280
+ // product with the user's permissions (\`adminMcp\`).
281
+ ${sortedImports([
282
+ `import { db } from '@${app.kebab}/db';`,
283
+ `import { adminEntitiesOf, defineAdmin } from '@ultimat3/admin';`,
284
+ ])}
235
285
 
236
- // \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
286
+ export const admin = defineAdmin({
287
+ // Every entity the handle serves: \`x g entity\` adds one there, and it is an admin screen on
288
+ // the next boot. Name them one by one instead — \`entities: [post]\` — to leave a table out.
289
+ entities: adminEntitiesOf(db),
290
+ db,
291
+ // Who may do what is the app's role map (shared/roles.ts), asked permission by permission:
292
+ // \`admin:read\` + \`<table>:read\` to look, \`admin:write\` + \`<table>:write\` to create or edit,
293
+ // \`admin:destroy\` + \`<table>:delete\` to delete. \`x g entity\` grants a new table's three to
294
+ // the \`admin\` role; a role holding only the two \`:read\` grants is a view-only operator.
295
+ // Per-entity overrides — list columns, scopes, a row scope, which columns are sensitive — go in
296
+ // \`resources: { <entity>: … }\`. \`x g resource <name> --admin\` writes one and lists it here.
297
+ });
298
+ `;
299
+
300
+ const adminDeclarationTest = (
301
+ app: NameSet,
302
+ ): string => `// The admin is one declaration, so what can go wrong is what it DERIVES: a route table with a
303
+ // screen behind every path, each gated — and an authz that asks the role map for every permission,
304
+ // so the admin role runs it and nobody else does.
305
+ //
306
+ // The role map is imported here because nothing else would load it: the boot scan reads
307
+ // shared/roles.ts before the first request, and a test that reaches the admin directly has to say
308
+ // so — without it the \`admin\` role grants nothing and every decision below is a refusal.
237
309
  ${sortedImports([
238
- `import { useT } from '@${app.kebab}/i18n';`,
239
- `import { defineRoute } from '@ultimat3/render';`,
310
+ `import { roles } from '@${app.kebab}/web/shared/roles';`,
311
+ `import { adminRoutes, permissionsForOperation } from '@ultimat3/admin';`,
312
+ `import { expect, unitTest } from '@ultimat3/testing';`,
240
313
  ])}
314
+ import { admin } from './admin';
315
+
316
+ const ctxFor = (roles: readonly string[]) =>
317
+ admin.ctx({ actor: { id: 'a', roles }, requestId: 'test' });
318
+
319
+ unitTest('every admin route is gated and has a screen', () => {
320
+ const routes = adminRoutes(admin);
321
+ expect(routes.map((route) => route.path)).toContain('/admin');
322
+ for (const route of routes) {
323
+ // The coarse gate is the first permission of the route's own pair: \`admin:read\` for a
324
+ // screen that reads, \`admin:write\` for a form. Never absent.
325
+ expect(route.config.policy?.permission).toBe(route.permissions[0] ?? '');
326
+ expect(route.config.policy?.permission.startsWith('admin:')).toBe(true);
327
+ expect(typeof route.respond).toBe('function');
328
+ }
329
+ });
241
330
 
242
- export const config = defineRoute({
243
- render: 'ssr',
244
- // Stated on a page whose body is one \`<h1>\`, and it costs nothing: the hydration runtime is
245
- // emitted per island DIRECTIVE, so \`hydrateRuntime([])\` is \`''\` and this document ships 0 bytes
246
- // (\`packages/render/src/hydrate.ts\`). It buys the first island being ONE file's edit —
247
- // \`hydrate: 'never'\` beside an island is \`X_ISLAND_NOT_HYDRATED\`, which defineRoute refuses.
248
- hydrate: 'idle',
249
- offline: 'network-only',
250
- // Behind auth, so the mode has to render per request: \`ssr\` and \`stream\` both do, and both take
251
- // a \`policy\`. \`static\` and \`isr\` refuse one outright — a file on disk has no actor to decide
252
- // against, and an ISR document is cached per URL, so the first actor's HTML would be served to
253
- // every later one who passes the same policy.
254
- policy: { permission: 'admin:read' },
255
- budget: { js: '120kb' },
256
- meta: ({ t }) => ({ title: t('admin.home.title'), description: t('admin.home.description') }),
331
+ unitTest('one resource per entity on the handle, each reading through it', () => {
332
+ for (const resource of admin.resources) {
333
+ expect(admin.routes.map((route) => route.path)).toContain(\`/admin\${resource.path}\`);
334
+ expect(resource.repo).toBeDefined();
335
+ }
257
336
  });
258
337
 
259
- export function AdminHome() {
260
- const t = useT();
338
+ unitTest('the admin role holds every permission the admin derives, and a member none', () => {
339
+ // Every route's pair and every operation's — a delete has no route of its own, it is a POST at
340
+ // the row's URL — asked of the role map by name: a table the handle gained without its three
341
+ // grants in shared/roles.ts fails HERE, not as a 403 an operator reports.
342
+ const required = [
343
+ ...new Set([
344
+ ...admin.routes.flatMap((route) => route.permissions),
345
+ ...admin.resources.flatMap((resource) =>
346
+ resource.operations.flatMap((op) => permissionsForOperation(resource.permission, op)),
347
+ ),
348
+ ]),
349
+ ];
350
+ expect(required).toContain('admin:read');
351
+ for (const permission of required) {
352
+ const asked = { permission, subject: { entity: 'any' } };
353
+ const granted = admin.authz.decide({ ...asked, actor: ctxFor(['admin']).actor });
354
+ expect({ permission, allowed: granted.allowed }).toEqual({ permission, allowed: true });
355
+ }
356
+ // Held whether or not the app has a table yet: the first \`x g entity\` must find them here.
357
+ expect(roles.admin.grants).toContain('admin:write');
358
+ expect(roles.admin.grants).toContain('admin:destroy');
359
+ const refused = admin.authz.decide({
360
+ permission: 'admin:write',
361
+ actor: ctxFor(['member']).actor,
362
+ });
363
+ expect(refused.allowed).toBe(false);
364
+ expect(refused.permission).toBe('admin:write');
365
+ });
261
366
 
262
- return <h1>{t('admin.home.title')}</h1>;
263
- }
367
+ unitTest('the dashboard answers 200 for the admin role and 403 for anyone else', async () => {
368
+ const [home] = adminRoutes(admin);
369
+ const ask = (roles: readonly string[]) =>
370
+ home?.respond({
371
+ ctx: ctxFor(roles),
372
+ params: {},
373
+ url: 'http://localhost/admin',
374
+ method: 'GET',
375
+ form: null,
376
+ });
377
+ const allowed = await ask(['admin']);
378
+ expect(allowed?.kind === 'document' && allowed.status).toBe(200);
379
+ const denied = await ask(['member']);
380
+ expect(denied?.kind === 'document' && denied.status).toBe(403);
381
+ });
264
382
  `;
265
383
 
266
384
  const placeholder = (surface: string, app: NameSet): string => `# ${surface}
@@ -280,7 +398,7 @@ restructure.
280
398
  * slice itself is written elsewhere. */
281
399
  export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile[] {
282
400
  return [
283
- { path: 'apps/web/package.json', contents: webPackage(app) },
401
+ { path: 'apps/web/package.json', contents: webPackage(app, example) },
284
402
  { path: 'apps/web/tsconfig.json', contents: tsconfig() },
285
403
  // The process a container starts and the artifact a CDN is handed — `scaffold-entries.ts`.
286
404
  ...entryFiles(),
@@ -302,12 +420,13 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
302
420
  // URL the generated service worker could not fall back to.
303
421
  { path: 'apps/web/site/offline/page.tsx', contents: offlineFallback(app) },
304
422
  { path: 'apps/web/site/offline/page.module.scss', contents: offlineStyle() },
305
- { path: 'apps/web/site/offline/page.test.ts', contents: offlineTest() },
423
+ { path: 'apps/web/site/offline/page.test.ts', contents: offlineTest(app) },
306
424
  // The third surface, and the one call that registers what the app declares — `scaffold-api.ts`.
307
425
  ...apiFiles(example),
308
426
  { path: 'apps/web/shared/tokens.scss', contents: sharedTokens() },
309
427
  { path: 'apps/web/shared/global.scss', contents: sharedGlobalStyle() },
310
428
  { path: 'apps/web/shared/global.ts', contents: sharedGlobalModule() },
429
+ { path: 'apps/web/shared/global.test.ts', contents: sharedGlobalTest() },
311
430
  { path: 'apps/web/shared/actor.ts', contents: sharedActor() },
312
431
  { path: 'apps/web/shared/actor.test.ts', contents: sharedActorTest() },
313
432
  // The one org the dev actor, the seed and the dashboard all name — `scaffold-demo-org.ts`.
@@ -319,12 +438,13 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
319
438
  ...rolesFiles(example),
320
439
  { path: 'apps/admin/package.json', contents: adminPackage(app) },
321
440
  { path: 'apps/admin/tsconfig.json', contents: tsconfig() },
322
- // `apps/admin/app/admin/page.tsx`, not `apps/admin/app/page.tsx`: the directory IS the URL,
323
- // relative to the surface root, so the shallower path resolves to `/` and collides with
324
- // `apps/web/site/page.tsx` — `x dev` loads both surfaces into one route table and the
325
- // scaffolded app failed its own `x routes` with X_ROUTE_DUPLICATE. `/admin` also matches
326
- // @ultimat3/admin's own `basePath` default, so the two agree instead of merely not clashing.
327
- { path: 'apps/admin/app/admin/page.tsx', contents: adminPage(app) },
441
+ // `apps/admin/app/admin/admin.ts`: a DECLARATION, not a page. `defineAdmin()` registers the
442
+ // admin and `x dev` mounts every screen it derives under `/admin` (`runtime-admin.ts`), so the
443
+ // app carries no route file for it. It sits under `app/` because that is where the boot scan
444
+ // looks — a module nothing imports declares nothing — and its test rides beside it, because
445
+ // generated source with no test is uncovered source in an app whose gate holds a floor.
446
+ { path: 'apps/admin/app/admin/admin.ts', contents: adminDeclaration(app) },
447
+ { path: 'apps/admin/app/admin/admin.test.ts', contents: adminDeclarationTest(app) },
328
448
  { path: 'apps/mobile/README.md', contents: placeholder('mobile', app) },
329
449
  { path: 'apps/desktop/README.md', contents: placeholder('desktop', app) },
330
450
  ];
@@ -36,35 +36,39 @@ const devActor = (
36
36
  import { type Actor, logger, tryResolveEnvironment } from '@ultimat3/core';
37
37
  import { configureAuthenticator, readCookie } from '@ultimat3/http';
38
38
  import { DEMO_ORG_ID } from '../../shared/demo-org';
39
+ import { roles } from '../../shared/roles';
39
40
 
40
- /** Set it to a role from \`apps/web/shared/roles.ts\` to browse as that role. */
41
+ /** Set it to a role \`apps/web/shared/roles.ts\` declares to browse as that role. */
41
42
  export const DEV_ROLE_COOKIE = '${app.kebab}_dev_role';
42
43
 
43
- /** The roles \`shared/roles.ts\` declares. A cookie naming anything else falls back. */
44
- export const DEV_ROLES = ['member', 'admin'] as const;
45
-
46
- export type DevRole = (typeof DEV_ROLES)[number];
47
-
48
- /** The one that can open every scaffolded route, including \`/admin\`. */
49
- export const DEFAULT_DEV_ROLE: DevRole = 'admin';
44
+ /** With NO cookie: the one that can open every scaffolded route, including \`/admin\`. */
45
+ export const DEFAULT_DEV_ROLE = 'admin';
50
46
 
51
- const isDevRole = (value: string | null): value is DevRole =>
52
- value !== null && (DEV_ROLES as readonly string[]).includes(value);
47
+ /**
48
+ * The roles a cookie may name: the ones \`shared/roles.ts\` declares, read off the map itself. A
49
+ * list spelled here went stale the day a role was added there — the new role was "unknown", and
50
+ * unknown fell back to admin, so browsing as a read-only role silently browsed as the most
51
+ * privileged one.
52
+ */
53
+ export const declaredDevRoles = (): readonly string[] => Object.keys(roles);
53
54
 
54
55
  /**
55
- * An unknown cookie value falls back rather than refusing: the cookie is a viewing convenience,
56
- * and a typo that resolved nobody would reproduce the 401 this module exists to remove.
56
+ * The role a request browses as, or \`undefined\` for a cookie naming a role nobody declared.
57
+ * No cookie at all is the default viewer; a cookie that names something else is a statement this
58
+ * module cannot honour, and it is never answered with more than was asked for.
57
59
  */
58
- export const devRoleFrom = (cookieHeader: string | null): DevRole => {
60
+ export const devRoleFrom = (cookieHeader: string | null): string | undefined => {
59
61
  const named = readCookie(cookieHeader, DEV_ROLE_COOKIE);
60
- return isDevRole(named) ? named : DEFAULT_DEV_ROLE;
62
+ if (named === null) return DEFAULT_DEV_ROLE;
63
+ // \`Object.hasOwn\`, never \`in\`: a cookie saying \`constructor\` names no role.
64
+ return Object.hasOwn(roles, named) ? named : undefined;
61
65
  };
62
66
 
63
67
  /**
64
68
  * \`roles\`, never a permission list: \`can()\` expands the role map at decision time, so a grant
65
69
  * moved between roles reaches this actor without an edit here.
66
70
  */
67
- export const devActorFor = (role: DevRole): Actor => ({
71
+ export const devActorFor = (role: string): Actor => ({
68
72
  kind: 'user',
69
73
  id: 'dev-actor',
70
74
  // The seed's org, never an invented string: a generated tenant policy compares it with a uuid.
@@ -77,6 +81,30 @@ export const devActorFor = (role: DevRole): Actor => ({
77
81
  permissions: [],
78
82
  });
79
83
 
84
+ /** The one method this reads off a request. */
85
+ interface HeaderRequest {
86
+ header(name: string): string | null;
87
+ }
88
+
89
+ /**
90
+ * What \`hooks.authenticate\` is handed: the request's cookie header decides the role. Named, and
91
+ * typed by the one method it reads, so a test calls it with a header and no server.
92
+ *
93
+ * A cookie naming an undeclared role answers \`null\` — ANONYMOUS, the least a request can be —
94
+ * and says so, with the value it read and the roles it could have named.
95
+ */
96
+ export const devAuthenticate = (request: HeaderRequest): Actor | null => {
97
+ const cookieHeader = request.header('cookie');
98
+ const role = devRoleFrom(cookieHeader);
99
+ if (role !== undefined) return devActorFor(role);
100
+ logger.warn('the development role cookie names no declared role: answering as anonymous', {
101
+ named: readCookie(cookieHeader, DEV_ROLE_COOKIE),
102
+ declared: declaredDevRoles(),
103
+ fix: \`document.cookie = '\${DEV_ROLE_COOKIE}=\${DEFAULT_DEV_ROLE}' # or declare the role in apps/web/shared/roles.ts\`,
104
+ });
105
+ return null;
106
+ };
107
+
80
108
  /**
81
109
  * Installs it, and says so — loudly, because a silent stand-in for authentication is the one thing
82
110
  * worse than none. Returns whether it installed, so the test can assert both halves.
@@ -85,7 +113,7 @@ export function installDevAuthenticator(
85
113
  env: Readonly<Record<string, string | undefined>> = process.env,
86
114
  ): boolean {
87
115
  if (tryResolveEnvironment({ env, fallback: 'production' }) !== 'development') return false;
88
- configureAuthenticator((request) => devActorFor(devRoleFrom(request.header('cookie'))));
116
+ configureAuthenticator(devAuthenticate);
89
117
  logger.warn('every request is answered as a development viewer', {
90
118
  role: DEFAULT_DEV_ROLE,
91
119
  cause:
@@ -103,6 +131,7 @@ installDevAuthenticator();
103
131
  const devActorTest =
104
132
  (): string => `// The two halves that make a development-only stand-in safe: it resolves the cookie, and it does
105
133
  // not install itself anywhere but development.
134
+ import { setLogSink } from '@ultimat3/core';
106
135
  import { configuredAuthenticator, resetAuthenticator } from '@ultimat3/http';
107
136
  import { actorHas } from '@ultimat3/policy';
108
137
  import { expect, unitTest } from '@ultimat3/testing';
@@ -110,15 +139,27 @@ import { roles } from '../../shared/roles';
110
139
  import {
111
140
  DEFAULT_DEV_ROLE,
112
141
  DEV_ROLE_COOKIE,
142
+ declaredDevRoles,
113
143
  devActorFor,
144
+ devAuthenticate,
114
145
  devRoleFrom,
115
146
  installDevAuthenticator,
116
147
  } from './dev-actor';
117
148
 
118
- unitTest('the cookie names the role, and anything else falls back', () => {
149
+ unitTest('the cookie names a DECLARED role; with no cookie it is the default viewer', () => {
119
150
  expect(devRoleFrom(\`\${DEV_ROLE_COOKIE}=member\`)).toBe('member');
120
- expect(devRoleFrom(\`\${DEV_ROLE_COOKIE}=nobody\`)).toBe(DEFAULT_DEV_ROLE);
121
151
  expect(devRoleFrom(null)).toBe(DEFAULT_DEV_ROLE);
152
+ // Read off the role map, so a role added to shared/roles.ts is one this cookie can name.
153
+ expect(declaredDevRoles()).toEqual(Object.keys(roles));
154
+ expect(declaredDevRoles()).toContain(DEFAULT_DEV_ROLE);
155
+ });
156
+
157
+ unitTest('a cookie naming an undeclared role is never the default viewer', () => {
158
+ // It fell back to admin: a typo, or a role not declared yet, browsed as the most privileged
159
+ // role there is — and a "read-only role cannot write" check passed for the wrong reason.
160
+ for (const named of ['nobody', 'Admin', 'constructor', '']) {
161
+ expect(devRoleFrom(\`\${DEV_ROLE_COOKIE}=\${named}\`)).toBeUndefined();
162
+ }
122
163
  });
123
164
 
124
165
  unitTest('the actor it mints holds what the role map grants it, and nothing else', () => {
@@ -147,10 +188,33 @@ unitTest('it installs in development and in no other environment', () => {
147
188
  expect(configuredAuthenticator()).toBeUndefined();
148
189
 
149
190
  expect(installDevAuthenticator({ ULTIMATE_ENV: 'development' })).toBe(true);
150
- expect(configuredAuthenticator()).toBeDefined();
191
+ expect(configuredAuthenticator()).toBe(devAuthenticate);
151
192
  // Process-global, so the case that installed one takes it back out.
152
193
  resetAuthenticator();
153
194
  });
195
+
196
+ unitTest('what it installs answers a request from its cookie header, and no other', () => {
197
+ const cookie = \`\${DEV_ROLE_COOKIE}=member\`;
198
+ const request = { header: (name: string) => (name === 'cookie' ? cookie : null) };
199
+ expect(devAuthenticate(request)?.roles).toEqual(['member']);
200
+ // A request with no cookie is the default viewer, never an anonymous one.
201
+ expect(devAuthenticate({ header: () => null })?.roles).toEqual([DEFAULT_DEV_ROLE]);
202
+ });
203
+
204
+ unitTest('an undeclared role is answered as ANONYMOUS, and the log names what it read', () => {
205
+ const lines: Record<string, unknown>[] = [];
206
+ const previous = setLogSink((line) => lines.push(JSON.parse(line) as Record<string, unknown>));
207
+ try {
208
+ const cookie = \`\${DEV_ROLE_COOKIE}=auditor\`;
209
+ expect(devAuthenticate({ header: () => cookie })).toBeNull();
210
+ } finally {
211
+ setLogSink(previous);
212
+ }
213
+ expect(lines).toHaveLength(1);
214
+ expect(lines[0]?.level).toBe('warn');
215
+ expect(lines[0]?.named).toBe('auditor');
216
+ expect(lines[0]?.declared).toEqual(Object.keys(roles));
217
+ });
154
218
  `;
155
219
 
156
220
  /** The app's development viewer, beside the roles it names. */
@@ -0,0 +1,90 @@
1
+ // `apps/web/shared/browser-client.ts` — the two typed clients an island calls, and the test beside
2
+ // them. Split from scaffold-api.ts because that file writes the SERVER half of the API surface;
3
+ // this is the browser's projection of it, shaped from `api/index.ts`'s `Api` type and nothing else.
4
+
5
+ import type { GeneratedFile } from './naming';
6
+
7
+ const browserClient = (): string => `/**
8
+ * The typed clients an ISLAND calls: \`browserClient.health({})\` writes, \`browserQueries.<name>(input)\`
9
+ * reads. Every action and query this app registers is a method here, typed from its own schemas,
10
+ * with no codegen step. The \`Api\` import is type-only, so no module edge reaches a feature's
11
+ * implementation and \`site/\` islands may use these too (\`site/\` may not import \`app/\`).
12
+ *
13
+ * Origin-relative: a browser posts to the page's own origin. No \`pathStyle\` either — the server
14
+ * stamps \`defineApi({ http: { pathStyle } })\` into every document and the client reads it there,
15
+ * so there is nothing here to keep in step.
16
+ *
17
+ * An island never calls \`fetch\`: a raw request in browser code is X_BROWSER_TRANSPORT_BYPASS on
18
+ * \`x verify\`. \`@ultimat3/query/client\`, not the barrel — the barrel carries the server's registry
19
+ * and is X_BROWSER_SERVER_BARREL in a browser chunk.
20
+ */
21
+
22
+ import { rpc } from '@ultimat3/action';
23
+ import { queryClient } from '@ultimat3/query/client';
24
+ import type { Api } from '../api';
25
+
26
+ export const browserClient = rpc<Api['actions']>({ baseUrl: '' });
27
+ export const browserQueries = queryClient<Api['queries']>({ baseUrl: '' });
28
+ `;
29
+
30
+ /** The read the example slice registers; `--no-example` registers none, so it asserts none. */
31
+ const exampleRead = `
32
+ unitTest('browserQueries reads a query over GET, its input in the search string', async () => {
33
+ const seen = await recording(
34
+ () => Response.json([]),
35
+ // No org: the read is the caller's own org, decided on the server from the session.
36
+ () => browserQueries.postList({ limit: 10 }),
37
+ );
38
+ expect(seen.answer).toEqual([]);
39
+ expect(seen.requests).toEqual([{ method: 'GET', url: '/_x/query/post-list?limit=10' }]);
40
+ });
41
+ `;
42
+
43
+ const browserClientTest = (
44
+ example: boolean,
45
+ ): string => `// The island's typed clients reach the server the way the server serves it: an action is a POST to
46
+ // its derived path on the page's own origin, a read is a GET under /_x/query. Asserted on the wire
47
+ // — the one place a renamed action or a moved origin would show.
48
+ import { expect, unitTest } from '@ultimat3/testing';
49
+ import { browserClient${example ? ', browserQueries' : ''} } from './browser-client';
50
+
51
+ interface Sent {
52
+ readonly method: string | undefined;
53
+ readonly url: string;
54
+ }
55
+
56
+ /** Runs \`call\` against a \`fetch\` that records each request and answers with \`respond()\`. */
57
+ async function recording<T>(
58
+ respond: () => Response,
59
+ call: () => Promise<T>,
60
+ ): Promise<{ readonly answer: T; readonly requests: readonly Sent[] }> {
61
+ const requests: Sent[] = [];
62
+ const real = globalThis.fetch;
63
+ globalThis.fetch = (async (url: string, init?: RequestInit) => {
64
+ requests.push({ method: init?.method, url });
65
+ return respond();
66
+ }) as typeof fetch;
67
+ try {
68
+ return { answer: await call(), requests };
69
+ } finally {
70
+ globalThis.fetch = real;
71
+ }
72
+ }
73
+
74
+ unitTest('browserClient posts an action to its derived path on the page origin', async () => {
75
+ const seen = await recording(
76
+ () => Response.json({ ok: true, role: 'web' }),
77
+ () => browserClient.health({}),
78
+ );
79
+ expect(seen.answer).toEqual({ ok: true, role: 'web' });
80
+ expect(seen.requests).toEqual([{ method: 'POST', url: '/api/healths/invoke' }]);
81
+ });
82
+ ${example ? exampleRead : ''}`;
83
+
84
+ /** The client and its test, in `shared/` because both `site/` and `app/` islands import it. */
85
+ export function browserClientFiles(example: boolean): readonly GeneratedFile[] {
86
+ return [
87
+ { path: 'apps/web/shared/browser-client.ts', contents: browserClient() },
88
+ { path: 'apps/web/shared/browser-client.test.ts', contents: browserClientTest(example) },
89
+ ];
90
+ }
@@ -7,6 +7,7 @@
7
7
  // buildpack, a Render blueprint or a fly.toml would be the primitive that never ships.
8
8
 
9
9
  import { SECRETS_KEY_FILE } from '@ultimat3/core';
10
+ import { PREBUILT_COMMAND } from '../serve-prebuilt-paths';
10
11
  import type { GeneratedFile, NameSet } from './naming';
11
12
  import { composeProdFile, PROD_ENV_FILE } from './scaffold-container-compose';
12
13
  import { helmFiles } from './scaffold-helm';
@@ -56,6 +57,16 @@ FROM deps AS runtime
56
57
  WORKDIR /app
57
58
  COPY . .
58
59
 
60
+ # ---------- prebuilt: every island chunk and compiled stylesheet, made once, here ----------
61
+ # What a web pod otherwise makes on EVERY boot — Babel over every island, Sass over every
62
+ # stylesheet: seconds of CPU and a compiler's worth of memory, per replica, per start. It runs in
63
+ # the image build because the store is valid only for the Bun and the paths that wrote it; above
64
+ # BUILD_ID, so two images differing only by their stamp share this layer; and above
65
+ # NODE_ENV=production, because it imports the app with no deployment environment and app.config.ts
66
+ # must ask for none — exactly as on a fresh clone. An image without this line still serves, and
67
+ # every boot logs X_IMAGE_NOT_PREBUILT naming it.
68
+ RUN ${PREBUILT_COMMAND}
69
+
59
70
  # The immutable content hash this image serves. Stamped by CI (\`--build-arg BUILD_ID=$(git rev-parse HEAD)\`);
60
71
  # without one the server computes the same hash from the manifest at boot. Never \`latest\`.
61
72
  ARG BUILD_ID=
@@ -153,6 +164,11 @@ docker run --rm -e ROLE=migrate -e DATABASE_URL=postgres://... ${app.kebab}:dev
153
164
  docker run -p 3000:3000 -e DATABASE_URL=postgres://... ${app.kebab}:dev
154
165
  \`\`\`
155
166
 
167
+ The image build runs \`x build --target prebuilt\` after \`COPY . .\`: every island chunk and every
168
+ compiled stylesheet is made once, there, by the image's own Bun — so a pod builds nothing at boot.
169
+ Without that line every boot runs Babel and Sass over the whole app and logs
170
+ \`X_IMAGE_NOT_PREBUILT\`, naming it.
171
+
156
172
  ## One box, every role
157
173
 
158
174
  \`\`\`sh