@ultimat3/cli 22.15.0 → 24.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 (267) hide show
  1. package/CLAUDE.md +36 -13
  2. package/README.md +31 -8
  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/affected.ts +27 -35
  7. package/src/api-registration.ts +125 -11
  8. package/src/app-artifacts.ts +1 -1
  9. package/src/app-auth.ts +4 -27
  10. package/src/app-boundaries.ts +6 -20
  11. package/src/app-config-load.ts +51 -0
  12. package/src/app-env.ts +5 -11
  13. package/src/app-load.ts +55 -11
  14. package/src/app-manifest.ts +16 -8
  15. package/src/app-mcp.ts +10 -23
  16. package/src/app-permissions-borrowed.ts +129 -0
  17. package/src/app-permissions-site.ts +24 -0
  18. package/src/app-permissions.ts +0 -0
  19. package/src/app-reload-graph.ts +7 -30
  20. package/src/app-transport.ts +219 -0
  21. package/src/boundary-findings.ts +16 -4
  22. package/src/browser-transport.ts +160 -0
  23. package/src/budgets.ts +27 -34
  24. package/src/cdp-shot-driver.ts +3 -1
  25. package/src/cmd-auth-spec.ts +16 -0
  26. package/src/cmd-auth.ts +65 -0
  27. package/src/cmd-build-spec.ts +10 -3
  28. package/src/cmd-build.ts +107 -11
  29. package/src/cmd-db-backfill.ts +25 -12
  30. package/src/cmd-db.ts +66 -16
  31. package/src/cmd-deploy-helm.ts +7 -6
  32. package/src/cmd-deploy.ts +4 -4
  33. package/src/cmd-dev.ts +9 -8
  34. package/src/cmd-doctor-spec.ts +2 -9
  35. package/src/cmd-doctor.ts +75 -24
  36. package/src/cmd-env.ts +26 -2
  37. package/src/cmd-generate.ts +195 -47
  38. package/src/cmd-help.ts +19 -6
  39. package/src/cmd-i18n.ts +10 -9
  40. package/src/cmd-jobs-spec.ts +19 -3
  41. package/src/cmd-jobs.ts +136 -7
  42. package/src/cmd-manifest-spec.ts +1 -1
  43. package/src/cmd-manifest.ts +6 -25
  44. package/src/cmd-mcp.ts +46 -15
  45. package/src/cmd-new.ts +15 -1
  46. package/src/cmd-planned.ts +15 -8
  47. package/src/cmd-routes.ts +27 -3
  48. package/src/cmd-secrets-ring.ts +76 -0
  49. package/src/cmd-secrets-spec.ts +10 -2
  50. package/src/cmd-secrets.ts +95 -18
  51. package/src/cmd-shot-matrix.ts +1 -3
  52. package/src/cmd-shot.ts +12 -3
  53. package/src/cmd-tasks.ts +84 -15
  54. package/src/cmd-test-spec.ts +1 -7
  55. package/src/cmd-test.ts +13 -29
  56. package/src/cmd-verify.ts +13 -5
  57. package/src/comment-safe.ts +16 -0
  58. package/src/coverage-floor.ts +334 -0
  59. package/src/coverage-lcov.ts +193 -0
  60. package/src/coverage-source.ts +136 -0
  61. package/src/db-object-drift.ts +30 -0
  62. package/src/db-replay-engine.ts +115 -0
  63. package/src/db-schema-dump.ts +150 -0
  64. package/src/db-schema-refresh.ts +122 -0
  65. package/src/db-subscribes.ts +12 -4
  66. package/src/dev-dashboard-guard.ts +76 -0
  67. package/src/dev-dashboard.ts +51 -29
  68. package/src/dev-lock.ts +4 -7
  69. package/src/dev-route-table.ts +9 -0
  70. package/src/dispatch.ts +3 -6
  71. package/src/doctor-app-url.ts +39 -0
  72. package/src/doctor-auth.ts +95 -0
  73. package/src/doctor-coverage.ts +39 -0
  74. package/src/doctor-guards.ts +54 -0
  75. package/src/doctor-sealed.ts +132 -0
  76. package/src/document-graph.ts +82 -0
  77. package/src/drift-replica-identity.ts +42 -0
  78. package/src/error-catalog.ts +6 -9
  79. package/src/error-codes.ts +55 -3
  80. package/src/error-unthrown.ts +29 -11
  81. package/src/errors.ts +54 -83
  82. package/src/framework-schema-apply.ts +78 -0
  83. package/src/framework-schema-stamp.ts +96 -0
  84. package/src/framework-schema.ts +28 -2
  85. package/src/generate-files.ts +79 -12
  86. package/src/generate-grant-findings.ts +76 -0
  87. package/src/generate-grants.ts +77 -3
  88. package/src/generate-kinds.ts +52 -1
  89. package/src/generate-shadow.ts +46 -0
  90. package/src/generate-write.ts +62 -40
  91. package/src/generated-imports.ts +59 -0
  92. package/src/gh-target.ts +12 -7
  93. package/src/guard-sources.ts +104 -0
  94. package/src/guards.ts +23 -7
  95. package/src/handle-registration.ts +217 -0
  96. package/src/i18n-audit.ts +4 -1
  97. package/src/i18n-registration.ts +36 -0
  98. package/src/image-prepare.ts +51 -8
  99. package/src/import-closure.ts +165 -0
  100. package/src/index.ts +12 -5
  101. package/src/island-bundle.ts +3 -15
  102. package/src/island-store.ts +24 -16
  103. package/src/jobs-drain.ts +95 -21
  104. package/src/jobs-json.ts +50 -0
  105. package/src/jobs-report.ts +7 -4
  106. package/src/json-merge.ts +46 -0
  107. package/src/loopback-host.ts +13 -0
  108. package/src/manifest-staleness.ts +62 -0
  109. package/src/mcp-errors.ts +41 -4
  110. package/src/mcp-host.ts +16 -9
  111. package/src/mcp-test-run.ts +38 -0
  112. package/src/messages.ts +35 -0
  113. package/src/metrics-endpoint.ts +95 -24
  114. package/src/migration-extensions.ts +29 -0
  115. package/src/module-imports.ts +47 -0
  116. package/src/output.ts +24 -9
  117. package/src/page-navigation.ts +7 -23
  118. package/src/page-speculation.ts +5 -20
  119. package/src/permission-grants.ts +41 -9
  120. package/src/prerender-out.ts +34 -7
  121. package/src/prerender.ts +8 -5
  122. package/src/publish-closure.ts +101 -0
  123. package/src/pwa-artifacts.ts +26 -94
  124. package/src/registry.ts +2 -0
  125. package/src/revalidated-response.ts +4 -3
  126. package/src/role-load.ts +143 -0
  127. package/src/role-replicator.ts +16 -0
  128. package/src/role-start.ts +27 -5
  129. package/src/role-sync.ts +55 -20
  130. package/src/role-wake.ts +45 -0
  131. package/src/runtime-admin.ts +141 -0
  132. package/src/runtime-assets.ts +11 -7
  133. package/src/runtime-bindings.ts +4 -4
  134. package/src/runtime-cache.ts +38 -47
  135. package/src/runtime-hooks.ts +5 -2
  136. package/src/runtime-idempotency-scope.ts +23 -0
  137. package/src/runtime-isr.ts +36 -0
  138. package/src/runtime-jobs.ts +8 -33
  139. package/src/runtime-mfa-warning.ts +29 -0
  140. package/src/runtime-notify-retention.ts +10 -46
  141. package/src/runtime-overrides.ts +1 -1
  142. package/src/runtime-purge.ts +38 -16
  143. package/src/runtime-queue.ts +39 -11
  144. package/src/runtime-realtime.ts +9 -45
  145. package/src/runtime-render.ts +68 -35
  146. package/src/runtime-services.ts +22 -18
  147. package/src/runtime-storage.ts +10 -5
  148. package/src/scaffold-typecheck.ts +1 -0
  149. package/src/schema-diff.ts +16 -0
  150. package/src/schema-drift.ts +45 -15
  151. package/src/schema-dump-drift.ts +64 -0
  152. package/src/seo-routes.ts +80 -9
  153. package/src/serve-boot.ts +114 -158
  154. package/src/serve-drain.ts +9 -33
  155. package/src/serve-env.ts +9 -6
  156. package/src/serve-prebuilt-paths.ts +19 -0
  157. package/src/serve-prebuilt.ts +84 -0
  158. package/src/serve-web.ts +169 -0
  159. package/src/serve.ts +6 -9
  160. package/src/server-barrels.ts +54 -0
  161. package/src/shot-locale.ts +5 -23
  162. package/src/site-assets.ts +3 -5
  163. package/src/site-config.ts +9 -48
  164. package/src/site-seo.ts +1 -1
  165. package/src/sitemap-lastmod.ts +5 -0
  166. package/src/solid-loader.ts +12 -3
  167. package/src/source-edit.ts +49 -0
  168. package/src/source-files.ts +6 -0
  169. package/src/stored-object-headers.ts +43 -0
  170. package/src/sync-url.ts +2 -2
  171. package/src/tasks-facts.ts +3 -26
  172. package/src/templates/action.ts +147 -11
  173. package/src/templates/admin-catalog.ts +58 -0
  174. package/src/templates/admin-page.ts +36 -8
  175. package/src/templates/admin.ts +57 -17
  176. package/src/templates/backfill.ts +121 -87
  177. package/src/templates/entity.ts +140 -44
  178. package/src/templates/guard-animated-layout-property.ts +4 -11
  179. package/src/templates/guard-bare-error.ts +9 -13
  180. package/src/templates/guard-focus-visible.ts +4 -11
  181. package/src/templates/guard-image-dimensions.ts +6 -6
  182. package/src/templates/guard-island-without-states.ts +2 -4
  183. package/src/templates/guard-raw-breakpoint.ts +210 -0
  184. package/src/templates/guard-raw-colour.ts +4 -11
  185. package/src/templates/guard-raw-length.ts +375 -0
  186. package/src/templates/guard-raw-motion.ts +213 -0
  187. package/src/templates/guard-raw-shadow.ts +187 -0
  188. package/src/templates/guard-raw-z-index.ts +166 -0
  189. package/src/templates/guard-repo-raw-sql.ts +345 -0
  190. package/src/templates/guard-semantic-interactive.ts +6 -6
  191. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  192. package/src/templates/guard-undefined-style-class.ts +215 -0
  193. package/src/templates/guard-untranslated-string.ts +6 -6
  194. package/src/templates/guard-unzoned-date.ts +9 -13
  195. package/src/templates/index.ts +3 -2
  196. package/src/templates/island.ts +34 -51
  197. package/src/templates/job-body-test.ts +121 -0
  198. package/src/templates/job.ts +23 -8
  199. package/src/templates/naming.ts +28 -0
  200. package/src/templates/policy.ts +16 -9
  201. package/src/templates/query.ts +242 -26
  202. package/src/templates/resource-create.ts +62 -18
  203. package/src/templates/resource-form-island.ts +58 -80
  204. package/src/templates/resource-page.ts +269 -0
  205. package/src/templates/resource-service.ts +96 -0
  206. package/src/templates/resource.ts +86 -61
  207. package/src/templates/route.ts +41 -35
  208. package/src/templates/scaffold-api.ts +55 -1
  209. package/src/templates/scaffold-app.ts +177 -53
  210. package/src/templates/scaffold-auth.ts +83 -19
  211. package/src/templates/scaffold-browser-client.ts +90 -0
  212. package/src/templates/scaffold-container-compose.ts +10 -1
  213. package/src/templates/scaffold-container.ts +20 -2
  214. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  215. package/src/templates/scaffold-dashboard-example.ts +124 -31
  216. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  217. package/src/templates/scaffold-dashboard.ts +1 -2
  218. package/src/templates/scaffold-db-client.ts +144 -0
  219. package/src/templates/scaffold-db-package.ts +90 -8
  220. package/src/templates/scaffold-docs.ts +50 -5
  221. package/src/templates/scaffold-guards.ts +54 -11
  222. package/src/templates/scaffold-helm-fences.ts +76 -0
  223. package/src/templates/scaffold-helm-templates.ts +32 -7
  224. package/src/templates/scaffold-helm.ts +31 -1
  225. package/src/templates/scaffold-http.ts +9 -7
  226. package/src/templates/scaffold-repo.ts +61 -3
  227. package/src/templates/scaffold-roles.ts +56 -9
  228. package/src/templates/scaffold-shell.ts +74 -105
  229. package/src/templates/scaffold-site.ts +45 -19
  230. package/src/templates/scaffold-ui-package.ts +18 -0
  231. package/src/templates/slice-foundation.ts +46 -8
  232. package/src/test-counts.ts +14 -2
  233. package/src/test-ignores.ts +25 -0
  234. package/src/test-passes.ts +0 -15
  235. package/src/test-select.ts +5 -14
  236. package/src/test-shards.ts +41 -93
  237. package/src/test-workers.ts +26 -4
  238. package/src/theme-boot.ts +6 -31
  239. package/src/transport-calls.ts +105 -0
  240. package/src/trusted-hops-error.ts +17 -0
  241. package/src/unscanned-admin.ts +54 -0
  242. package/src/unthrown-codes.ts +26 -0
  243. package/src/verify-checks.ts +43 -53
  244. package/src/verify-coverage-run.ts +190 -0
  245. package/src/verify-deadline.ts +353 -0
  246. package/src/verify-floor.ts +36 -9
  247. package/src/verify-merge.ts +171 -45
  248. package/src/verify-part-step.ts +90 -0
  249. package/src/verify-progress.ts +25 -0
  250. package/src/verify-role-load-probe.ts +54 -0
  251. package/src/verify-role-load.ts +148 -0
  252. package/src/verify-run.ts +116 -37
  253. package/src/verify-shard.ts +33 -11
  254. package/src/verify-stalled.ts +152 -0
  255. package/src/verify-step.ts +42 -1
  256. package/src/verify-tests.ts +83 -8
  257. package/src/workspace-checks.ts +50 -8
  258. package/src/workspace-dep-edit.ts +127 -0
  259. package/src/build-errors.ts +0 -54
  260. package/src/cmd-dev-fixture.ts +0 -204
  261. package/src/dev-live-fixture.ts +0 -122
  262. package/src/policy-fixture.ts +0 -78
  263. package/src/realtime-browser-probe-fixture.ts +0 -9
  264. package/src/role-start-fixture.ts +0 -78
  265. package/src/scaffold-fixture.ts +0 -160
  266. package/src/templates/island-fixture.ts +0 -76
  267. package/src/verify-errors.ts +0 -49
@@ -16,11 +16,21 @@ import { rolesFiles } from './scaffold-roles';
16
16
  import { shellFiles } from './scaffold-shell';
17
17
  import { siteFiles } from './scaffold-site';
18
18
 
19
+ // `typecheck` is the ROOT program, as in every workspace `x new` writes: a type extension declared in
20
+ // one workspace (`PermissionRegistry` in `app/*/policy.ts`) is in force only where its file is in
21
+ // the program, and the app's own `tsconfig.json` passed in `apps/admin` what the gate refuses.
22
+ //
19
23
  // The one dependency this manifest names, and it is not decoration: every page below reads its
20
24
  // strings through `@<app>/i18n`'s `useT()`, so the surface that renders a string DEPENDS on the
21
25
  // module that registers the catalogs. An undeclared workspace dependency resolves through the root
22
26
  // symlink and then breaks the day the app is built anywhere else.
23
- const webPackage = (app: NameSet): string => `{
27
+ //
28
+ // `@<app>/db` joins it with the example slice, whose repo.ts reads through the typed handle that
29
+ // package exports. Under `--no-example` nothing here imports it yet, so it is not declared — the
30
+ // first `x g entity` writes the line with the repo that needs it (`handle-registration.ts`).
31
+ const webPackage = (app: NameSet, example: boolean): string => {
32
+ const db = example ? `\n "@${app.kebab}/db": "0.0.0",` : '';
33
+ return `{
24
34
  "name": "@${app.kebab}/web",
25
35
  "version": "0.0.0",
26
36
  "private": true,
@@ -30,13 +40,14 @@ const webPackage = (app: NameSet): string => `{
30
40
  "./*.tsx": "./*.tsx"
31
41
  },
32
42
  "scripts": {
33
- "typecheck": "tsc --noEmit -p tsconfig.json"
43
+ "typecheck": "tsc --noEmit -p ../../tsconfig.json"
34
44
  },
35
- "dependencies": {
45
+ "dependencies": {${db}
36
46
  "@${app.kebab}/i18n": "0.0.0"
37
47
  }
38
48
  }
39
49
  `;
50
+ };
40
51
 
41
52
  // The ambient \`*.module.scss\` declaration is not reachable through an import, so a program that
42
53
  // only sees this app's files would report TS2307 on every stylesheet. Naming it in \`include\`
@@ -47,27 +58,39 @@ const tsconfig = (): string => `{
47
58
  }
48
59
  `;
49
60
 
50
- const offlineTest =
51
- (): string => `// The offline fallback has to render with nothing: no network, no session, no database, and no
61
+ const offlineTest = (
62
+ app: NameSet,
63
+ ): string => `// The offline fallback has to render with nothing: no network, no session, no database, and no
52
64
  // JavaScript. Every one of those is a config field here, and every one of them rots the moment
53
65
  // someone adds an import or a policy — at which point the page the service worker precaches is a
54
66
  // 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';
67
+ ${sortedImports([
68
+ `import { useT } from '@${app.kebab}/i18n';`,
69
+ "import { expect, renderRoute, unitTest } from '@ultimat3/testing';",
70
+ ])}
71
+ import * as page from './page';
58
72
 
59
- const ctx = { params: {}, url: 'https://example.test/offline' };
73
+ const url = 'https://example.test/offline';
74
+
75
+ unitTest('the offline fallback renders with no actor, and says the network is gone', async () => {
76
+ const t = useT();
77
+ // No actor and no context: exactly what the service worker has when it serves this.
78
+ const view = await renderRoute(page, { url });
79
+ expect(view.html.match(/<h1\\b/g)).toHaveLength(1);
80
+ expect(view.text).toBe(\`\${t('app.offline.title')} \${t('app.offline.description')}\`);
81
+ expect(view.islands).toEqual([]);
82
+ });
60
83
 
61
84
  unitTest('the offline fallback is static, precached, and ships no JavaScript', async () => {
62
- expect(config.render).toBe('static');
85
+ const view = await renderRoute(page, { url });
86
+ expect(page.config.render).toBe('static');
63
87
  // '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');
88
+ expect(page.config.offline).toBe('precache');
89
+ expect(page.config.hydrate).toBe('never');
90
+ expect(page.config.budget.js).toBe('0kb');
67
91
  // A cached error page has nothing to index, and an indexed one outranks the page it stood in for
68
92
  // 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);
93
+ expect(view.meta.robots?.index).toBe(false);
71
94
  });
72
95
  `;
73
96
 
@@ -103,7 +126,7 @@ export const config = defineRoute({
103
126
  }),
104
127
  });
105
128
 
106
- export function OfflinePage() {
129
+ export function Page() {
107
130
  const t = useT();
108
131
 
109
132
  return (
@@ -167,6 +190,25 @@ const sharedGlobalModule =
167
190
  import './global.scss';
168
191
  `;
169
192
 
193
+ const sharedGlobalTest =
194
+ (): string => `// The global layer reaches this app's module graph through exactly one edge. Cut it and every
195
+ // \`var(--…)\` a component emits resolves to nothing: the app renders unstyled, with every test of
196
+ // every component still green.
197
+ import { registeredStylesheets } from '@ultimat3/render/server';
198
+ import { expect, unitTest } from '@ultimat3/testing';
199
+ import './global';
200
+
201
+ unitTest('importing shared/global registers the one global stylesheet', () => {
202
+ const sheets = registeredStylesheets().filter((sheet) => sheet.global);
203
+ expect(sheets.map((sheet) => sheet.file.split('/').slice(-3).join('/'))).toEqual([
204
+ 'web/shared/global.scss',
205
+ ]);
206
+ // The custom properties themselves, declared once on the root — what \`tokens.role()\` refers to.
207
+ expect(sheets[0]?.css).toContain(':root{');
208
+ expect(sheets[0]?.css).toContain('--color-bg:');
209
+ });
210
+ `;
211
+
170
212
  const sharedActor =
171
213
  (): string => `// The actor type both surfaces agree on. Policies read this and nothing else, so authz cannot
172
214
  // disagree between HTTP, live queries, jobs and MCP.
@@ -208,8 +250,10 @@ unitTest('holds answers from the role map, and an anonymous actor holds nothing'
208
250
  });
209
251
  `;
210
252
 
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()`.
253
+ // Two dependencies, and they are the edges this workspace has: `app/admin/admin.ts` reads the
254
+ // app's typed handle from `@<app>/db`, and its test loads the role map from `@<app>/web`.
255
+ // Undeclared, they exist only inside the root tsconfig's `paths`, where `bun --filter` and every
256
+ // change-detection tool are blind to them.
213
257
  const adminPackage = (app: NameSet): string => `{
214
258
  "name": "@${app.kebab}/admin",
215
259
  "version": "0.0.0",
@@ -220,47 +264,125 @@ const adminPackage = (app: NameSet): string => `{
220
264
  "./*.tsx": "./*.tsx"
221
265
  },
222
266
  "scripts": {
223
- "typecheck": "tsc --noEmit -p tsconfig.json"
267
+ "typecheck": "tsc --noEmit -p ../../tsconfig.json"
224
268
  },
225
269
  "dependencies": {
226
- "@${app.kebab}/i18n": "0.0.0"
270
+ "@${app.kebab}/db": "0.0.0",
271
+ "@${app.kebab}/web": "0.0.0"
227
272
  }
228
273
  }
229
274
  `;
230
275
 
231
- const adminPage = (
276
+ const adminDeclaration = (
232
277
  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.
278
+ ): string => `// The whole admin dashboard. \`defineAdmin()\` serves a list, a detail and a form for every entity
279
+ // below at /admin/<entity> — the columns, filters, validation and labels are derived from the
280
+ // entity, the rows are read through the app's own typed handle, and \`x dev\` mounts the screens.
281
+ // There is no page file, no repo adapter and no screen glue to write.
282
+ //
283
+ // It also ships an MCP surface over the same resources, so the user's agents can drive the user's
284
+ // product with the user's permissions (\`adminMcp\`).
285
+ ${sortedImports([
286
+ `import { db } from '@${app.kebab}/db';`,
287
+ `import { adminEntitiesOf, defineAdmin } from '@ultimat3/admin';`,
288
+ ])}
235
289
 
236
- // \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
290
+ export const admin = defineAdmin({
291
+ // Every entity the handle serves: \`x g entity\` adds one there, and it is an admin screen on
292
+ // the next boot. Name them one by one instead — \`entities: [post]\` — to leave a table out.
293
+ entities: adminEntitiesOf(db),
294
+ db,
295
+ // Who may do what is the app's role map (shared/roles.ts), asked permission by permission:
296
+ // \`admin:read\` + \`<table>:read\` to look, \`admin:write\` + \`<table>:write\` to create or edit,
297
+ // \`admin:destroy\` + \`<table>:delete\` to delete. \`x g entity\` grants a new table's three to
298
+ // the \`admin\` role; a role holding only the two \`:read\` grants is a view-only operator.
299
+ // Per-entity overrides — list columns, scopes, a row scope, which columns are sensitive — go in
300
+ // \`resources: { <entity>: … }\`. \`x g resource <name> --admin\` writes one and lists it here.
301
+ });
302
+ `;
303
+
304
+ const adminDeclarationTest = (
305
+ app: NameSet,
306
+ ): string => `// The admin is one declaration, so what can go wrong is what it DERIVES: a route table with a
307
+ // screen behind every path, each gated — and an authz that asks the role map for every permission,
308
+ // so the admin role runs it and nobody else does.
309
+ //
310
+ // The role map is imported here because nothing else would load it: the boot scan reads
311
+ // shared/roles.ts before the first request, and a test that reaches the admin directly has to say
312
+ // so — without it the \`admin\` role grants nothing and every decision below is a refusal.
237
313
  ${sortedImports([
238
- `import { useT } from '@${app.kebab}/i18n';`,
239
- `import { defineRoute } from '@ultimat3/render';`,
314
+ `import { roles } from '@${app.kebab}/web/shared/roles';`,
315
+ `import { adminRoutes, permissionsForOperation } from '@ultimat3/admin';`,
316
+ `import { expect, unitTest } from '@ultimat3/testing';`,
240
317
  ])}
318
+ import { admin } from './admin';
319
+
320
+ const ctxFor = (roles: readonly string[]) =>
321
+ admin.ctx({ actor: { id: 'a', roles }, requestId: 'test' });
322
+
323
+ unitTest('every admin route is gated and has a screen', () => {
324
+ const routes = adminRoutes(admin);
325
+ expect(routes.map((route) => route.path)).toContain('/admin');
326
+ for (const route of routes) {
327
+ // The coarse gate is the first permission of the route's own pair: \`admin:read\` for a
328
+ // screen that reads, \`admin:write\` for a form. Never absent.
329
+ expect(route.config.policy?.permission).toBe(route.permissions[0] ?? '');
330
+ expect(route.config.policy?.permission.startsWith('admin:')).toBe(true);
331
+ expect(typeof route.respond).toBe('function');
332
+ }
333
+ });
241
334
 
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') }),
335
+ unitTest('one resource per entity on the handle, each reading through it', () => {
336
+ for (const resource of admin.resources) {
337
+ expect(admin.routes.map((route) => route.path)).toContain(\`/admin\${resource.path}\`);
338
+ expect(resource.repo).toBeDefined();
339
+ }
257
340
  });
258
341
 
259
- export function AdminHome() {
260
- const t = useT();
342
+ unitTest('the admin role holds every permission the admin derives, and a member none', () => {
343
+ // Every route's pair and every operation's — a delete has no route of its own, it is a POST at
344
+ // the row's URL — asked of the role map by name: a table the handle gained without its three
345
+ // grants in shared/roles.ts fails HERE, not as a 403 an operator reports.
346
+ const required = [
347
+ ...new Set([
348
+ ...admin.routes.flatMap((route) => route.permissions),
349
+ ...admin.resources.flatMap((resource) =>
350
+ resource.operations.flatMap((op) => permissionsForOperation(resource.permission, op)),
351
+ ),
352
+ ]),
353
+ ];
354
+ expect(required).toContain('admin:read');
355
+ for (const permission of required) {
356
+ const asked = { permission, subject: { entity: 'any' } };
357
+ const granted = admin.authz.decide({ ...asked, actor: ctxFor(['admin']).actor });
358
+ expect({ permission, allowed: granted.allowed }).toEqual({ permission, allowed: true });
359
+ }
360
+ // Held whether or not the app has a table yet: the first \`x g entity\` must find them here.
361
+ expect(roles.admin.grants).toContain('admin:write');
362
+ expect(roles.admin.grants).toContain('admin:destroy');
363
+ const refused = admin.authz.decide({
364
+ permission: 'admin:write',
365
+ actor: ctxFor(['member']).actor,
366
+ });
367
+ expect(refused.allowed).toBe(false);
368
+ expect(refused.permission).toBe('admin:write');
369
+ });
261
370
 
262
- return <h1>{t('admin.home.title')}</h1>;
263
- }
371
+ unitTest('the dashboard answers 200 for the admin role and 403 for anyone else', async () => {
372
+ const [home] = adminRoutes(admin);
373
+ const ask = (roles: readonly string[]) =>
374
+ home?.respond({
375
+ ctx: ctxFor(roles),
376
+ params: {},
377
+ url: 'http://localhost/admin',
378
+ method: 'GET',
379
+ form: null,
380
+ });
381
+ const allowed = await ask(['admin']);
382
+ expect(allowed?.kind === 'document' && allowed.status).toBe(200);
383
+ const denied = await ask(['member']);
384
+ expect(denied?.kind === 'document' && denied.status).toBe(403);
385
+ });
264
386
  `;
265
387
 
266
388
  const placeholder = (surface: string, app: NameSet): string => `# ${surface}
@@ -280,7 +402,7 @@ restructure.
280
402
  * slice itself is written elsewhere. */
281
403
  export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile[] {
282
404
  return [
283
- { path: 'apps/web/package.json', contents: webPackage(app) },
405
+ { path: 'apps/web/package.json', contents: webPackage(app, example) },
284
406
  { path: 'apps/web/tsconfig.json', contents: tsconfig() },
285
407
  // The process a container starts and the artifact a CDN is handed — `scaffold-entries.ts`.
286
408
  ...entryFiles(),
@@ -302,12 +424,13 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
302
424
  // URL the generated service worker could not fall back to.
303
425
  { path: 'apps/web/site/offline/page.tsx', contents: offlineFallback(app) },
304
426
  { path: 'apps/web/site/offline/page.module.scss', contents: offlineStyle() },
305
- { path: 'apps/web/site/offline/page.test.ts', contents: offlineTest() },
427
+ { path: 'apps/web/site/offline/page.test.ts', contents: offlineTest(app) },
306
428
  // The third surface, and the one call that registers what the app declares — `scaffold-api.ts`.
307
429
  ...apiFiles(example),
308
430
  { path: 'apps/web/shared/tokens.scss', contents: sharedTokens() },
309
431
  { path: 'apps/web/shared/global.scss', contents: sharedGlobalStyle() },
310
432
  { path: 'apps/web/shared/global.ts', contents: sharedGlobalModule() },
433
+ { path: 'apps/web/shared/global.test.ts', contents: sharedGlobalTest() },
311
434
  { path: 'apps/web/shared/actor.ts', contents: sharedActor() },
312
435
  { path: 'apps/web/shared/actor.test.ts', contents: sharedActorTest() },
313
436
  // The one org the dev actor, the seed and the dashboard all name — `scaffold-demo-org.ts`.
@@ -319,12 +442,13 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
319
442
  ...rolesFiles(example),
320
443
  { path: 'apps/admin/package.json', contents: adminPackage(app) },
321
444
  { 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) },
445
+ // `apps/admin/app/admin/admin.ts`: a DECLARATION, not a page. `defineAdmin()` registers the
446
+ // admin and `x dev` mounts every screen it derives under `/admin` (`runtime-admin.ts`), so the
447
+ // app carries no route file for it. It sits under `app/` because that is where the boot scan
448
+ // looks — a module nothing imports declares nothing — and its test rides beside it, because
449
+ // generated source with no test is uncovered source in an app whose gate holds a floor.
450
+ { path: 'apps/admin/app/admin/admin.ts', contents: adminDeclaration(app) },
451
+ { path: 'apps/admin/app/admin/admin.test.ts', contents: adminDeclarationTest(app) },
328
452
  { path: 'apps/mobile/README.md', contents: placeholder('mobile', app) },
329
453
  { path: 'apps/desktop/README.md', contents: placeholder('desktop', app) },
330
454
  ];
@@ -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
+ }
@@ -25,6 +25,11 @@ export const composeProdFile = (
25
25
  #
26
26
  # docker compose --env-file ${PROD_ENV_FILE} -f docker/docker-compose.prod.yml up -d
27
27
  #
28
+ # Two origins the file cannot know, so ${PROD_ENV_FILE} states them: SYNC_URL, where a page dials
29
+ # the socket (ws://<host>:3001/_x/sync), and APP_URL, where the pages are served
30
+ # (http://<host>:3000) — the sync node admits a socket from APP_URL's origin and no other once
31
+ # declared; undeclared, from the origin it was reached on.
32
+ #
28
33
  # A published host port has exactly one binder, so \`web\` and \`sync\` run at 1 here. Compose is one
29
34
  # box; horizontal scaling of those two belongs to an orchestrator — \`docker/helm\`, beside this
30
35
  # file, is the chart \`x deploy --method helm\` installs. To scale them on one box anyway, drop
@@ -129,7 +134,11 @@ services:
129
134
 
130
135
  sync:
131
136
  <<: *image
132
- environment: [ROLE=sync]
137
+ # The page is :3000 and this socket :3001 — two origins — and the node admits an upgrade from
138
+ # APP_URL's origin and no other once declared (scheme, host and port, exactly); undeclared, from
139
+ # the origin it was reached on. Unset, every websocket is 403 X_SOCKET_ORIGIN_REFUSED, so it is
140
+ # required: the same value web builds its links from.
141
+ environment: [ROLE=sync, 'APP_URL=\${APP_URL:?set APP_URL=http://<host>:3000 — the origin the pages are served on, see wiki/Deployment.md}']
133
142
  depends_on:
134
143
  db: { condition: service_healthy }
135
144
  migrate: { condition: service_completed_successfully }