@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.
- package/CLAUDE.md +23 -8
- package/README.md +28 -5
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/api-registration.ts +36 -11
- package/src/app-boundaries.ts +6 -20
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +14 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +25 -1
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +56 -9
- package/src/cmd-db.ts +60 -11
- package/src/cmd-doctor.ts +35 -11
- package/src/cmd-generate.ts +118 -17
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +120 -3
- package/src/cmd-new.ts +15 -1
- package/src/cmd-routes.ts +15 -1
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +48 -12
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-verify.ts +10 -2
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/dev-dashboard.ts +2 -2
- package/src/dev-route-table.ts +9 -0
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/error-codes.ts +32 -0
- package/src/framework-schema.ts +15 -0
- package/src/generate-files.ts +76 -10
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +19 -0
- package/src/generate-write.ts +15 -27
- package/src/generated-imports.ts +59 -0
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +11 -2
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +14 -7
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +3 -0
- package/src/json-merge.ts +46 -0
- package/src/mcp-errors.ts +29 -1
- package/src/messages.ts +33 -0
- package/src/metrics-endpoint.ts +71 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +18 -9
- package/src/permission-grants.ts +41 -9
- package/src/publish-closure.ts +101 -0
- package/src/role-load.ts +143 -0
- package/src/role-start.ts +18 -1
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-queue.ts +8 -1
- package/src/runtime-render.ts +6 -0
- package/src/runtime-services.ts +2 -1
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-dump-drift.ts +64 -0
- package/src/serve-boot.ts +85 -157
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +164 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/source-edit.ts +49 -0
- package/src/tasks-facts.ts +4 -1
- package/src/templates/action.ts +144 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +38 -29
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +171 -51
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container.ts +16 -0
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +142 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +52 -2
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +36 -70
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +18 -2
- package/src/transport-calls.ts +105 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/verify-checks.ts +26 -7
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +256 -0
- package/src/verify-errors.ts +15 -6
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +117 -24
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +93 -35
- package/src/verify-shard.ts +20 -9
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +78 -6
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
import {
|
|
57
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
212
|
-
//
|
|
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}/
|
|
266
|
+
"@${app.kebab}/db": "0.0.0",
|
|
267
|
+
"@${app.kebab}/web": "0.0.0"
|
|
227
268
|
}
|
|
228
269
|
}
|
|
229
270
|
`;
|
|
230
271
|
|
|
231
|
-
const
|
|
272
|
+
const adminDeclaration = (
|
|
232
273
|
app: NameSet,
|
|
233
|
-
): string => `// The
|
|
234
|
-
//
|
|
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
|
-
|
|
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 {
|
|
239
|
-
`import {
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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/
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
{ path: 'apps/admin/app/admin/
|
|
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
|
|
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
|
-
/**
|
|
44
|
-
export 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
|
-
|
|
52
|
-
|
|
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
|
-
*
|
|
56
|
-
*
|
|
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):
|
|
60
|
+
export const devRoleFrom = (cookieHeader: string | null): string | undefined => {
|
|
59
61
|
const named = readCookie(cookieHeader, DEV_ROLE_COOKIE);
|
|
60
|
-
|
|
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:
|
|
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(
|
|
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
|
|
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()).
|
|
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
|