@ultimat3/cli 23.0.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 (149) hide show
  1. package/CLAUDE.md +15 -7
  2. package/README.md +3 -3
  3. package/package.json +28 -28
  4. package/src/affected.ts +27 -35
  5. package/src/api-registration.ts +89 -0
  6. package/src/app-artifacts.ts +1 -1
  7. package/src/app-auth.ts +4 -27
  8. package/src/app-config-load.ts +51 -0
  9. package/src/app-env.ts +5 -11
  10. package/src/app-manifest.ts +2 -8
  11. package/src/app-mcp.ts +10 -23
  12. package/src/app-permissions-borrowed.ts +129 -0
  13. package/src/app-permissions-site.ts +24 -0
  14. package/src/app-permissions.ts +7 -17
  15. package/src/budgets.ts +4 -35
  16. package/src/cdp-shot-driver.ts +3 -1
  17. package/src/cmd-auth-spec.ts +16 -0
  18. package/src/cmd-auth.ts +65 -0
  19. package/src/cmd-build.ts +64 -15
  20. package/src/cmd-db-backfill.ts +25 -12
  21. package/src/cmd-db.ts +6 -5
  22. package/src/cmd-deploy-helm.ts +7 -6
  23. package/src/cmd-deploy.ts +4 -4
  24. package/src/cmd-dev.ts +9 -8
  25. package/src/cmd-doctor-spec.ts +2 -9
  26. package/src/cmd-doctor.ts +41 -14
  27. package/src/cmd-env.ts +26 -2
  28. package/src/cmd-generate.ts +79 -32
  29. package/src/cmd-help.ts +19 -6
  30. package/src/cmd-i18n.ts +10 -9
  31. package/src/cmd-jobs.ts +18 -6
  32. package/src/cmd-manifest-spec.ts +1 -1
  33. package/src/cmd-manifest.ts +6 -25
  34. package/src/cmd-mcp.ts +46 -15
  35. package/src/cmd-planned.ts +15 -8
  36. package/src/cmd-routes.ts +12 -2
  37. package/src/cmd-secrets.ts +47 -6
  38. package/src/cmd-shot-matrix.ts +1 -3
  39. package/src/cmd-shot.ts +12 -3
  40. package/src/cmd-tasks.ts +2 -2
  41. package/src/cmd-test-spec.ts +1 -7
  42. package/src/cmd-test.ts +13 -29
  43. package/src/cmd-verify.ts +4 -4
  44. package/src/db-subscribes.ts +12 -4
  45. package/src/dev-dashboard-guard.ts +76 -0
  46. package/src/dev-dashboard.ts +51 -29
  47. package/src/dev-lock.ts +4 -7
  48. package/src/dispatch.ts +3 -6
  49. package/src/doctor-app-url.ts +39 -0
  50. package/src/doctor-auth.ts +95 -0
  51. package/src/drift-replica-identity.ts +42 -0
  52. package/src/error-catalog.ts +6 -9
  53. package/src/error-codes.ts +23 -3
  54. package/src/error-unthrown.ts +29 -11
  55. package/src/errors.ts +54 -83
  56. package/src/framework-schema-apply.ts +78 -0
  57. package/src/framework-schema-stamp.ts +96 -0
  58. package/src/framework-schema.ts +13 -2
  59. package/src/generate-files.ts +4 -3
  60. package/src/generate-kinds.ts +33 -1
  61. package/src/generate-shadow.ts +46 -0
  62. package/src/generate-write.ts +47 -13
  63. package/src/gh-target.ts +12 -7
  64. package/src/i18n-audit.ts +4 -1
  65. package/src/index.ts +1 -3
  66. package/src/island-bundle.ts +3 -15
  67. package/src/jobs-drain.ts +82 -15
  68. package/src/jobs-report.ts +4 -4
  69. package/src/loopback-host.ts +13 -0
  70. package/src/manifest-staleness.ts +62 -0
  71. package/src/mcp-errors.ts +12 -3
  72. package/src/mcp-host.ts +16 -9
  73. package/src/mcp-test-run.ts +38 -0
  74. package/src/messages.ts +2 -0
  75. package/src/metrics-endpoint.ts +25 -1
  76. package/src/output.ts +6 -0
  77. package/src/page-navigation.ts +7 -23
  78. package/src/page-speculation.ts +5 -20
  79. package/src/prerender-out.ts +34 -7
  80. package/src/prerender.ts +8 -5
  81. package/src/pwa-artifacts.ts +26 -94
  82. package/src/registry.ts +2 -0
  83. package/src/revalidated-response.ts +4 -3
  84. package/src/role-replicator.ts +16 -0
  85. package/src/role-start.ts +9 -4
  86. package/src/role-sync.ts +55 -20
  87. package/src/runtime-assets.ts +11 -7
  88. package/src/runtime-bindings.ts +4 -4
  89. package/src/runtime-cache.ts +38 -47
  90. package/src/runtime-idempotency-scope.ts +23 -0
  91. package/src/runtime-isr.ts +36 -0
  92. package/src/runtime-jobs.ts +8 -33
  93. package/src/runtime-mfa-warning.ts +29 -0
  94. package/src/runtime-notify-retention.ts +10 -46
  95. package/src/runtime-overrides.ts +1 -1
  96. package/src/runtime-purge.ts +38 -16
  97. package/src/runtime-queue.ts +31 -10
  98. package/src/runtime-realtime.ts +9 -45
  99. package/src/runtime-render.ts +62 -35
  100. package/src/runtime-services.ts +20 -17
  101. package/src/runtime-storage.ts +10 -5
  102. package/src/schema-diff.ts +16 -0
  103. package/src/schema-drift.ts +45 -15
  104. package/src/seo-routes.ts +80 -9
  105. package/src/serve-boot.ts +34 -6
  106. package/src/serve-drain.ts +9 -33
  107. package/src/serve-env.ts +9 -6
  108. package/src/serve-web.ts +12 -7
  109. package/src/shot-locale.ts +5 -23
  110. package/src/site-assets.ts +3 -5
  111. package/src/site-config.ts +9 -48
  112. package/src/site-seo.ts +1 -1
  113. package/src/sitemap-lastmod.ts +5 -0
  114. package/src/solid-loader.ts +12 -3
  115. package/src/source-files.ts +6 -0
  116. package/src/stored-object-headers.ts +43 -0
  117. package/src/sync-url.ts +2 -2
  118. package/src/tasks-facts.ts +3 -29
  119. package/src/templates/action.ts +3 -0
  120. package/src/templates/naming.ts +28 -0
  121. package/src/templates/route.ts +3 -6
  122. package/src/templates/scaffold-app.ts +6 -2
  123. package/src/templates/scaffold-container-compose.ts +10 -1
  124. package/src/templates/scaffold-container.ts +4 -2
  125. package/src/templates/scaffold-db-client.ts +4 -2
  126. package/src/templates/scaffold-helm-fences.ts +76 -0
  127. package/src/templates/scaffold-helm-templates.ts +32 -7
  128. package/src/templates/scaffold-helm.ts +31 -1
  129. package/src/templates/scaffold-repo.ts +9 -1
  130. package/src/templates/scaffold-shell.ts +39 -36
  131. package/src/test-counts.ts +2 -6
  132. package/src/test-ignores.ts +25 -0
  133. package/src/test-passes.ts +0 -15
  134. package/src/test-select.ts +5 -14
  135. package/src/test-shards.ts +41 -93
  136. package/src/test-workers.ts +26 -4
  137. package/src/theme-boot.ts +6 -31
  138. package/src/trusted-hops-error.ts +17 -0
  139. package/src/unthrown-codes.ts +26 -0
  140. package/src/verify-checks.ts +17 -46
  141. package/src/verify-deadline.ts +130 -33
  142. package/src/verify-merge.ts +60 -27
  143. package/src/verify-part-step.ts +90 -0
  144. package/src/verify-run.ts +29 -8
  145. package/src/verify-shard.ts +13 -2
  146. package/src/verify-stalled.ts +152 -0
  147. package/src/verify-tests.ts +5 -2
  148. package/src/build-errors.ts +0 -54
  149. package/src/verify-errors.ts +0 -58
package/src/serve-boot.ts CHANGED
@@ -5,13 +5,15 @@
5
5
  // (`serve-web.ts`) and the manifest projection are behind `await import()` in turn, so a worker's
6
6
  // module graph holds neither.
7
7
 
8
- import type { Role } from '@ultimat3/core';
8
+ import type { DrainConfig, LifecycleOptions, Role } from '@ultimat3/core';
9
9
  import { configureLifecycle, logger } from '@ultimat3/core';
10
10
  import type { MetricsEndpoint } from './metrics-endpoint';
11
11
  import { startOtlpExport } from './otlp-export';
12
12
  import { loadAppForRole, roleLoadFor } from './role-load';
13
13
  import { startRoles } from './role-start';
14
14
  import { resolveServices } from './runtime-bindings';
15
+ import { warnIfIdempotencyProcessScoped } from './runtime-idempotency-scope';
16
+ import { warnUnsealedMfaSecrets } from './runtime-mfa-warning';
15
17
  import { replicaOverrides } from './runtime-replica';
16
18
  import type { RunningServices } from './runtime-services';
17
19
  import { startServices } from './runtime-services';
@@ -42,6 +44,23 @@ async function buildIdFor(options: ServeOptions, role: Role): Promise<string> {
42
44
  return (await appManifest(options.root)).manifest.buildId;
43
45
  }
44
46
 
47
+ /** The roles that open an HTTP listener something routes to — the only ones a readiness grace serves. */
48
+ const LISTENING_ROLES: readonly Role[] = ['web', 'sync'];
49
+
50
+ /**
51
+ * `app.config.ts`'s `drain`, applied to the WHOLE process rather than only to the web server that
52
+ * `createServer` hands it to. A role nothing routes to gets no readiness grace at all: the grace
53
+ * holds `/readyz` at 503 with the listener open so the endpoints catch up, and a worker has neither
54
+ * — it only went on claiming jobs for those seconds, then aborted them at the drain (s1-con #7).
55
+ */
56
+ export function lifecycleForRole(
57
+ role: Role,
58
+ drain: Partial<DrainConfig> | undefined,
59
+ ): LifecycleOptions {
60
+ if (!LISTENING_ROLES.includes(role)) return { readinessGraceMs: 0 };
61
+ return drain?.readinessGraceMs === undefined ? {} : { readinessGraceMs: drain.readinessGraceMs };
62
+ }
63
+
45
64
  /**
46
65
  * Everything `serveApp` does after the scrape listener is open: the services, then the roles over
47
66
  * them. One export, so `serve.ts` reaches the whole serving graph through ONE `await import()`.
@@ -53,11 +72,12 @@ export async function bootServing(boot: {
53
72
  readonly metrics: MetricsEndpoint;
54
73
  }): Promise<ServedApp> {
55
74
  const { options } = boot;
56
- const runtime = await startServices(
57
- resolveServices(options.root, options.env),
58
- options.env,
59
- options.runtime,
60
- );
75
+ const services = resolveServices(options.root, options.env);
76
+ // A serving role on a real database runs no DDL: `ROLE=migrate` applied this build's framework
77
+ // tables first, and this boot only verifies the stamp it left (`framework-schema-apply.ts`).
78
+ // The embedded database has no migrate step to have run, so it applies its own.
79
+ const schema = services.db.mode === 'external' ? 'verify' : 'apply';
80
+ const runtime = await startServices(services, options.env, options.runtime, schema);
61
81
  boot.acquired.push(() => runtime.stop());
62
82
  return bootRoles({ ...boot, runtime });
63
83
  }
@@ -84,6 +104,8 @@ async function bootRoles(boot: {
84
104
  for (const finding of loaded.findings) {
85
105
  logger.error('ultimate app module failed to load', { role, ...finding });
86
106
  }
107
+ // After the app's modules: an app swaps the store at import time, after `startServices`.
108
+ warnIfIdempotencyProcessScoped();
87
109
  const buildId = await buildIdFor(options, role);
88
110
  // Before the first socket opens: everything above this line fails loudly into the container's
89
111
  // own logs, everything below it is a served request, a claimed job or a routed frame.
@@ -102,6 +124,9 @@ async function bootRoles(boot: {
102
124
  role === 'web'
103
125
  ? await (await import('./serve-web')).webSurface(options, runtime, buildId)
104
126
  : undefined;
127
+ if (web !== undefined) acquired.push(web.release);
128
+ // The web role alone — it is where a second factor is asked for — and once per pod boot.
129
+ if (role === 'web') await warnUnsealedMfaSecrets(runtime.db);
105
130
  // Islands and stylesheets are the image build's to make (`x build --target prebuilt`). A role
106
131
  // that made either here served correctly and paid for it — seconds of CPU and a compiler's
107
132
  // memory, on every start of every replica — so it says so once, with the Dockerfile line.
@@ -112,6 +137,7 @@ async function bootRoles(boot: {
112
137
  // still wins — that is the deploy talking.
113
138
  const metricsPort = metricsPortFor(options.env, port, options.metricsPort);
114
139
  const drain = await loadDrainConfig(options.root);
140
+ configureLifecycle(lifecycleForRole(role, drain));
115
141
  // Process-wide, so every role's `/readyz` — web, sync, worker — answers in the declared mode.
116
142
  const health = await loadHealthConfig(options.root);
117
143
  if (health?.readiness !== undefined) configureLifecycle({ readiness: health.readiness });
@@ -156,6 +182,8 @@ async function bootRoles(boot: {
156
182
  runtime,
157
183
  async stop() {
158
184
  await running.stop();
185
+ // After the server drained: a bust landing mid-drain still marks the pages it is serving.
186
+ web?.release();
159
187
  await runtime.stop();
160
188
  // Last: the exporters outlive the roles they were recording, so the drain's own spans and
161
189
  // the final counter snapshot still have somewhere to go.
@@ -1,41 +1,17 @@
1
- // `app.config.ts`'s `drain` and `health` sections, read for the production boot — the same import
2
- // every other config reader here makes (`theme-boot.ts`, `app-auth.ts`), because the config is a
3
- // module. Both shape what `/readyz` tells the load balancer.
1
+ // `app.config.ts`'s `drain` and `health` sections for the production boot, off the one loader
2
+ // (`app-config-load.ts`). Both shape what `/readyz` tells the load balancer.
4
3
 
5
4
  import type { DrainConfig, HealthConfig } from '@ultimat3/core';
6
- import { APP_CONFIG_EXPORT } from './app-auth';
7
- import { APP_CONFIG_FILE } from './app-root';
5
+ import { loadAppConfig } from './app-config-load';
8
6
 
9
- const isRecord = (value: unknown): value is Record<string, unknown> =>
10
- typeof value === 'object' && value !== null;
11
-
12
- /**
13
- * The declared section, or `undefined` when the app has none (a config not built by
14
- * `defineConfig`, or no config file): `@ultimat3/http` then keeps core's own default. A value that
15
- * is not a number is left for core to refuse, where the rule lives, rather than dropped here.
16
- */
7
+ /** The declared section, or `undefined` for a root with no config file — core keeps its default. */
17
8
  export async function loadDrainConfig(root: string): Promise<Partial<DrainConfig> | undefined> {
18
- const path = `${root}/${APP_CONFIG_FILE}`;
19
- if (!(await Bun.file(path).exists())) return undefined;
20
- const module = (await import(path)) as Record<string, unknown>;
21
- const config = module[APP_CONFIG_EXPORT];
22
- const drain = isRecord(config) ? config['drain'] : undefined;
23
- if (!isRecord(drain) || !Object.hasOwn(drain, 'readinessGraceMs')) return undefined;
24
- return { readinessGraceMs: drain['readinessGraceMs'] as number };
9
+ const drain = (await loadAppConfig(root))?.drain;
10
+ return drain === undefined ? undefined : { readinessGraceMs: drain.readinessGraceMs };
25
11
  }
26
12
 
27
- /**
28
- * `health.readiness`, or `undefined` when the app declares none (core keeps `'dependencies'`). The
29
- * value is handed to `configureLifecycle` as written, so a bad one is refused by core's own rule.
30
- */
13
+ /** `health.readiness`, or `undefined` for a root with no config file (core keeps its default). */
31
14
  export async function loadHealthConfig(root: string): Promise<Partial<HealthConfig> | undefined> {
32
- const path = `${root}/${APP_CONFIG_FILE}`;
33
- if (!(await Bun.file(path).exists())) return undefined;
34
- const module = (await import(path)) as Record<string, unknown>;
35
- const config = module[APP_CONFIG_EXPORT];
36
- const health = isRecord(config)
37
- ? (config['health'] as Partial<HealthConfig> | undefined)
38
- : undefined;
39
- if (!isRecord(health) || health.readiness === undefined) return undefined;
40
- return { readiness: health.readiness };
15
+ const health = (await loadAppConfig(root))?.health;
16
+ return health === undefined ? undefined : { readiness: health.readiness };
41
17
  }
package/src/serve-env.ts CHANGED
@@ -42,22 +42,25 @@ export const containerBinding = (env: Env, hostname?: string): WebBinding => ({
42
42
  /**
43
43
  * `ROLE` is the one knob one image exposes. Validated rather than defaulted: a typo that fell back
44
44
  * to `web` would start a process that serves nothing the operator asked for and reports healthy.
45
+ * Empty or blank is unset, read the way `PORT` and `HOST` are — not a role name nobody typed.
45
46
  */
46
47
  export function roleFromEnv(env: Env): Role {
47
- const raw = env['ROLE'] ?? 'web';
48
- if (!isRole(raw)) throw new RoleUnknownError({ role: raw, known: ROLES });
49
- return raw;
48
+ const raw = env['ROLE']?.trim();
49
+ const role = raw === undefined || raw.length === 0 ? 'web' : raw;
50
+ if (!isRole(role)) throw new RoleUnknownError({ role, known: ROLES });
51
+ return role;
50
52
  }
51
53
 
52
54
  /**
53
- * `Number.parseInt` would read `80abc` as 80, so the whole string has to be a port — a
54
- * partially-parsed port is a deploy that binds somewhere nobody asked for.
55
+ * `Number.parseInt` would read `80abc` as 80, and `Number` reads `0x1F90` as 8080 and `8e3` as
56
+ * 8000, so the whole string has to be decimal digits — a port spelled any other way is a deploy
57
+ * that binds somewhere nobody wrote down.
55
58
  */
56
59
  function portValue(env: Env, name: string, fallback: number): number {
57
60
  const raw = env[name];
58
61
  if (raw === undefined || raw.trim().length === 0) return fallback;
59
62
  const port = Number(raw.trim());
60
- if (!Number.isInteger(port) || port < 0 || port > 65_535)
63
+ if (!/^\d+$/.test(raw.trim()) || !Number.isInteger(port) || port < 0 || port > 65_535)
61
64
  throw new PortInvalidError({ value: raw, name });
62
65
  return port;
63
66
  }
package/src/serve-web.ts CHANGED
@@ -5,7 +5,6 @@
5
5
 
6
6
  import type { Route } from '@ultimat3/http';
7
7
  import { describeRoutes } from '@ultimat3/render';
8
- import { createIsrController } from '@ultimat3/render/server';
9
8
  import { apiMountRoutes, apiRoutes, pagePostRoutes } from './api-routes';
10
9
  import { loadSignInPath } from './app-auth';
11
10
  import { mountAppMcp } from './app-mcp';
@@ -19,6 +18,7 @@ import { pageSync } from './page-sync';
19
18
  import { loadPwaArtifacts } from './pwa-artifacts';
20
19
  import { adminMountRoutes } from './runtime-admin';
21
20
  import { assetRoutes } from './runtime-assets';
21
+ import { attachedIsr } from './runtime-isr';
22
22
  import { appRoutes } from './runtime-render';
23
23
  import type { RunningServices } from './runtime-services';
24
24
  import { servedStorage, storageRoutes } from './runtime-storage';
@@ -42,6 +42,8 @@ export interface WebSurface {
42
42
  readonly signInPath: string | null;
43
43
  /** Present when this boot built the islands itself: how many chunks, and why not the store. */
44
44
  readonly islandsBuilt?: LoadedIslands['built'];
45
+ /** Detaches the ISR controller from `invalidateTags` — on the boot's stop list. */
46
+ readonly release: () => void;
45
47
  }
46
48
 
47
49
  export async function webSurface(
@@ -93,6 +95,13 @@ export async function webSurface(
93
95
  const mcpMount = await mountAppMcp(options.root);
94
96
  // The app's own disks when it declared any (`defineStorage` in an app module), else this boot's.
95
97
  const served = servedStorage(runtime.storage);
98
+ // Attached, so a tag bust marks this process's ISR pages stale. The store is the deployment's
99
+ // when it supplied one: the default is per process, so twelve replicas hold twelve of them and a
100
+ // purge regenerates one twelfth of the fleet while the other eleven keep the invalidated page.
101
+ const isr = attachedIsr({
102
+ buildId,
103
+ ...(options.runtime?.isrStore === undefined ? {} : { store: options.runtime.isrStore }),
104
+ });
96
105
  const routes: readonly Route[] = [
97
106
  ...apiRoutes(),
98
107
  // The bearer doors onto a cut of the same routes (`defineApi({ http: { mounts } })`), counted
@@ -142,12 +151,7 @@ export async function webSurface(
142
151
  ...(pwa === undefined
143
152
  ? {}
144
153
  : { pwaHead: (locale: string) => pwa.headFor(locale) + (serviceWorker?.head ?? '') }),
145
- // Only when a store was supplied. `createIsrController` defaults to a per-process memory
146
- // store, so twelve replicas hold twelve of them and a purge tag regenerates one twelfth of
147
- // the fleet while the other eleven keep serving the page it just invalidated.
148
- ...(options.runtime?.isrStore === undefined
149
- ? {}
150
- : { isr: createIsrController({ buildId, store: options.runtime.isrStore }) }),
154
+ isr: isr.isr,
151
155
  }),
152
156
  ];
153
157
  return {
@@ -160,5 +164,6 @@ export async function webSurface(
160
164
  // guarded page with the problem document, rendered as raw JSON in the viewport.
161
165
  signInPath: await loadSignInPath(options.root),
162
166
  ...(islandsBuilt === undefined ? {} : { islandsBuilt }),
167
+ release: isr.release,
163
168
  };
164
169
  }
@@ -3,9 +3,7 @@
3
3
  // on the language of the machine's Chrome. A non-default locale is also a URL prefix (`/en/…`),
4
4
  // because on a `site/` route the unprefixed path is always the default locale whatever the header.
5
5
 
6
- import { join } from 'node:path'; // why: Bun ships no path join.
7
- import { APP_CONFIG_EXPORT } from './app-auth';
8
- import { APP_CONFIG_FILE } from './app-root';
6
+ import { loadAppConfig } from './app-config-load';
9
7
  import { BadFlagError } from './errors';
10
8
 
11
9
  export interface ShotLocales {
@@ -16,27 +14,11 @@ export interface ShotLocales {
16
14
  /** An app that declares nothing is `en` only — the framework's own default. */
17
15
  export const FALLBACK_SHOT_LOCALES: ShotLocales = { locales: ['en'], defaultLocale: 'en' };
18
16
 
19
- const isRecord = (value: unknown): value is Record<string, unknown> =>
20
- typeof value === 'object' && value !== null;
21
-
22
- /** `app.config.ts`'s `locales` and `defaultLocale`, read the way `loadThemeMode` reads `theme`. */
17
+ /** `app.config.ts`'s `locales` and `defaultLocale`, off the one loader (`app-config-load.ts`). */
23
18
  export async function loadShotLocales(root: string): Promise<ShotLocales> {
24
- const configPath = join(root, APP_CONFIG_FILE);
25
- if (!(await Bun.file(configPath).exists())) return FALLBACK_SHOT_LOCALES;
26
- const module = (await import(configPath)) as Record<string, unknown>;
27
- const config = module[APP_CONFIG_EXPORT];
28
- if (!isRecord(config)) return FALLBACK_SHOT_LOCALES;
29
- const declared = config['locales'];
30
- const locales = Array.isArray(declared)
31
- ? declared.filter((locale): locale is string => typeof locale === 'string')
32
- : [];
33
- const fallback = config['defaultLocale'];
34
- const defaultLocale =
35
- typeof fallback === 'string' && fallback !== '' ? fallback : (locales[0] ?? 'en');
36
- return {
37
- locales: locales.length === 0 ? [defaultLocale] : locales,
38
- defaultLocale,
39
- };
19
+ const config = await loadAppConfig(root);
20
+ if (config === undefined) return FALLBACK_SHOT_LOCALES;
21
+ return { locales: config.locales, defaultLocale: config.defaultLocale };
40
22
  }
41
23
 
42
24
  /** `--locale <l>`, refused by name when the app does not declare it — a typo costs no browser. */
@@ -9,6 +9,7 @@ import { copyFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
9
9
  import { dirname, join } from 'node:path'; // why: Bun ships no path API.
10
10
  import type { AssetExtension, AssetPath } from '@ultimat3/render';
11
11
  import { ASSET_DIR, AssetMissingError, assetPathProblem } from '@ultimat3/render';
12
+ import { contentHash } from '@ultimat3/render/server';
12
13
 
13
14
  /** App-root-relative, beside `favicon.ico`: `apps/web/site/` is where an app's public files live. */
14
15
  export const SITE_ASSETS_SOURCE = `apps/web/site/${ASSET_DIR}`;
@@ -37,7 +38,7 @@ export interface SiteAsset {
37
38
  readonly path: AssetPath;
38
39
  /** Absolute path on disk. */
39
40
  readonly file: string;
40
- /** xxHash32 of the bytes, 8 hex characters — the algorithm `contentHash` uses for CSS. */
41
+ /** `@ultimat3/render`'s `contentHash` of the bytes, 8 hex characters — the one CSS and HTML get. */
41
42
  readonly hash: string;
42
43
  /** `/assets/brand/logo.<hash>.svg`. */
43
44
  readonly url: string;
@@ -69,9 +70,6 @@ export function parseHashedAssetUrl(
69
70
  return { path: path as AssetPath, hash: match[2] ?? '' };
70
71
  }
71
72
 
72
- const hashBytes = (bytes: Uint8Array): string =>
73
- Bun.hash.xxHash32(bytes).toString(16).padStart(8, '0');
74
-
75
73
  export interface SiteAssetTable {
76
74
  /** The asset `asset()` named, hashed as the bytes are NOW. Throws `X_ASSET_MISSING`. */
77
75
  resolve(path: AssetPath): SiteAsset;
@@ -110,7 +108,7 @@ export function createSiteAssetTable(root: string): SiteAssetTable {
110
108
  const cached = cache.get(path);
111
109
  if (cached !== undefined && cached.stamp === stamp) return cached.asset;
112
110
  const bytes = readFileSync(file);
113
- const hash = hashBytes(bytes);
111
+ const hash = contentHash(bytes);
114
112
  // `assetPathProblem` already refused an unknown extension; the guard keeps `constructor` from
115
113
  // ever answering an `Object` member if that check is loosened.
116
114
  const extension = splitExtension(path).extension.toLowerCase();
@@ -1,14 +1,10 @@
1
1
  // The public origin and the crawler rules out of `app.config.ts` — `site.origin` and
2
2
  // `seo.robots.disallow` — and the one resolution of "which origin is this site served on" every
3
- // absolute URL a document, a sitemap or a robots file carries is built against. Sibling of
4
- // `app-auth.ts`'s `loadSignInPath`, and it imports the config for the same reason.
3
+ // absolute URL a document, a sitemap or a robots file carries is built against. Read off the one
4
+ // loader (`app-config-load.ts`).
5
5
 
6
- // why: Bun exposes no path-join primitive, and the config path is app-root-relative.
7
- import { join } from 'node:path';
8
- import type { AppConfig, Environment, SeoSitemapConfig, SitemapLastmod } from '@ultimat3/core';
9
- import { SITEMAP_LASTMOD_SOURCES } from '@ultimat3/core';
10
- import { APP_CONFIG_EXPORT } from './app-auth';
11
- import { APP_CONFIG_FILE } from './app-root';
6
+ import type { Environment, SeoSitemapConfig } from '@ultimat3/core';
7
+ import { loadAppConfig } from './app-config-load';
12
8
 
13
9
  export interface SiteSettings {
14
10
  /** `site.origin`, trailing slash removed; `null` when the app declares none. */
@@ -25,51 +21,16 @@ export const NO_SITE_SETTINGS: SiteSettings = {
25
21
  sitemap: { extra: [], lastmod: 'none' },
26
22
  };
27
23
 
28
- const isRecord = (value: unknown): value is Record<string, unknown> =>
29
- typeof value === 'object' && value !== null;
30
-
31
- /**
32
- * Structural, never `instanceof`, for `loadSignInPath`'s reason: a config that resolved through an
33
- * older core simply has no `site` section, and that is the "not declared" answer, not a crash.
34
- */
35
- const hasSiteSections = (value: unknown): value is Pick<AppConfig, 'site' | 'seo'> =>
36
- isRecord(value) &&
37
- isRecord(value['site']) &&
38
- isRecord(value['seo']) &&
39
- isRecord(value['seo']['robots']);
40
-
41
24
  const trimSlash = (origin: string): string => origin.replace(/\/+$/, '');
42
25
 
43
26
  export async function loadSiteSettings(root: string): Promise<SiteSettings> {
44
- const configPath = join(root, APP_CONFIG_FILE);
45
- if (!(await Bun.file(configPath).exists())) return NO_SITE_SETTINGS;
46
- const module = (await import(configPath)) as Record<string, unknown>;
47
- const config = module[APP_CONFIG_EXPORT];
48
- if (!hasSiteSections(config)) return NO_SITE_SETTINGS;
49
- const origin = config.site.origin;
27
+ const config = await loadAppConfig(root);
28
+ if (config === undefined) return NO_SITE_SETTINGS;
29
+ const { origin } = config.site;
50
30
  return {
51
- origin: typeof origin === 'string' && origin !== '' ? trimSlash(origin) : null,
31
+ origin: origin === null ? null : trimSlash(origin),
52
32
  disallow: [...config.seo.robots.disallow],
53
- sitemap: sitemapOf(config.seo),
54
- };
55
- }
56
-
57
- /**
58
- * Structural for `hasSiteSections`' reason: a config resolved through a core older than
59
- * `seo.sitemap` has no such key, and that is the default, not a crash.
60
- */
61
- function sitemapOf(seo: unknown): SeoSitemapConfig {
62
- const declared = isRecord(seo) ? seo['sitemap'] : undefined;
63
- if (!isRecord(declared)) return NO_SITE_SETTINGS.sitemap;
64
- const extra = declared['extra'];
65
- const lastmod = declared['lastmod'];
66
- return {
67
- extra: Array.isArray(extra)
68
- ? extra.filter((path): path is string => typeof path === 'string')
69
- : [],
70
- lastmod: SITEMAP_LASTMOD_SOURCES.includes(lastmod as SitemapLastmod)
71
- ? (lastmod as SitemapLastmod)
72
- : 'none',
33
+ sitemap: config.seo.sitemap,
73
34
  };
74
35
  }
75
36
 
package/src/site-seo.ts CHANGED
@@ -173,7 +173,7 @@ export async function siteSeo(options: SiteSeoOptions): Promise<SiteSeo> {
173
173
  localizedPath(path, locale, defaultLocale),
174
174
  }),
175
175
  });
176
- // Past 50,000 URLs `files` are `/sitemap-N.xml` and the index is `/sitemap.xml`; below it,
176
+ // Past 50,000 URLs `files` are `/sitemaps/<n>.xml` and the index is `/sitemap.xml`; below it,
177
177
  // `files` is that one file and there is no index.
178
178
  const sitemaps = sitemap.index === undefined ? sitemap.files : [sitemap.index, ...sitemap.files];
179
179
  const robots = buildRobots({
@@ -14,6 +14,9 @@ let builtAt: string | undefined;
14
14
 
15
15
  const cache = new Map<string, string | undefined>();
16
16
 
17
+ /** One `git log -1` over one path; a synchronous wait past this blocks the request serving it. */
18
+ const GIT_DATE_TIMEOUT_MS = 5_000;
19
+
17
20
  /**
18
21
  * The `<lastmod>` for one route file, W3C Datetime (ISO 8601, UTC), or `undefined` when the source
19
22
  * yields none — a sitemap entry without `<lastmod>` is valid, a wrong one misleads a crawler.
@@ -46,6 +49,8 @@ function gitDate(file: string, root: string): string | undefined {
46
49
  cwd: root,
47
50
  stdout: 'pipe',
48
51
  stderr: 'ignore',
52
+ // A wedged `git` (a lock, a credential prompt) answers no date: the mtime fallback below.
53
+ timeout: GIT_DATE_TIMEOUT_MS,
49
54
  });
50
55
  if (run.exitCode !== 0) return undefined;
51
56
  return isoOf(run.stdout.toString().trim());
@@ -73,6 +73,10 @@ interface CacheEntry {
73
73
  */
74
74
  const cache = new Map<string, CacheEntry>();
75
75
 
76
+ /** Transforms running now, and the `prepareStackTrace` the first of them found (see below). */
77
+ let inFlight = 0;
78
+ let foundStackTrace: typeof Error.prepareStackTrace;
79
+
76
80
  /** Test seam: the cache is process-global because the dev server it serves is too. */
77
81
  export function clearIslandTransformCache(): void {
78
82
  cache.clear();
@@ -92,8 +96,12 @@ export async function transformIslandTsx(source: string, path: string): Promise<
92
96
 
93
97
  // Babel installs its own `prepareStackTrace` on the first TRANSFORM — not on import, which is
94
98
  // where this guard was first put — and leaving it installed makes `Error.captureStackTrace`
95
- // strict for every unrelated module loaded later in the same process.
96
- const saved = Error.prepareStackTrace;
99
+ // strict for every unrelated module loaded later in the same process, and names every error in
100
+ // it `Error` (a `TypeError`'s stack then opens `Error: …`). Saved by the FIRST transform in
101
+ // flight and restored by the LAST: `Bun.build` loads a second `.tsx` while the first compiles,
102
+ // and a per-call save taken inside that window saved Babel's own rewriter and restored it last.
103
+ if (inFlight === 0) foundStackTrace = Error.prepareStackTrace;
104
+ inFlight += 1;
97
105
  let code: string | null | undefined;
98
106
  try {
99
107
  // `transformAsync` and never `transformFileAsync`: the latter is gated behind `@babel/core`'s
@@ -111,7 +119,8 @@ export async function transformIslandTsx(source: string, path: string): Promise<
111
119
  });
112
120
  code = result?.code;
113
121
  } finally {
114
- Error.prepareStackTrace = saved;
122
+ inFlight -= 1;
123
+ if (inFlight === 0) Error.prepareStackTrace = foundStackTrace;
115
124
  }
116
125
  // A parse error is NOT caught here: Babel's own message already names the file, the line and the
117
126
  // column ("a.island.tsx: Unexpected token (2:23)"), and `buildOne` wraps whatever escapes in
@@ -16,6 +16,12 @@ export const SOURCE_GLOBS = [
16
16
  'api/**/*.{ts,tsx}',
17
17
  'shared/**/*.{ts,tsx}',
18
18
  'apps/*/{app,site,api,shared}/**/*.{ts,tsx}',
19
+ // An app's own entry files: authored, shipped, and outside every surface glob above — a 900-line
20
+ // `server.ts` or an unrunnable `fix:` in it was invisible to `filesize` and `errors`.
21
+ // And its guards: run on the gate, held to the error contract, and invisible to `filesize`.
22
+ 'guards/*.ts',
23
+ 'apps/*/server.ts',
24
+ 'apps/*/prerender.ts',
19
25
  ] as const;
20
26
 
21
27
  /**
@@ -0,0 +1,43 @@
1
+ // How a stored object may be presented when the app's own origin serves it (plan 101, s1-sec L6):
2
+ // inline only for a type a browser renders without running anything, a sandboxed download otherwise. Read by
3
+ // both surfaces that serve stored bytes — `/_storage` (`runtime-storage.ts`) and `/media`.
4
+
5
+ /**
6
+ * Raster images, audio and video: what an `<img>`, `<audio>` or `<video>` presents and nothing
7
+ * executes. NOT `image/svg+xml` (it carries `<script>`), `text/html`, `application/xml` or PDF —
8
+ * any of them opened from the app's origin runs with the app's cookies and its `'self'` sources.
9
+ */
10
+ const INLINE_TYPES: ReadonlySet<string> = new Set([
11
+ 'image/png',
12
+ 'image/jpeg',
13
+ 'image/gif',
14
+ 'image/webp',
15
+ 'image/avif',
16
+ 'audio/mpeg',
17
+ 'audio/ogg',
18
+ 'audio/wav',
19
+ 'audio/webm',
20
+ 'video/mp4',
21
+ 'video/webm',
22
+ 'video/ogg',
23
+ ]);
24
+
25
+ /** `image/PNG; charset=…` is `image/png`: parameters and case never widen the list. */
26
+ const essenceOf = (contentType: string): string =>
27
+ (contentType.split(';')[0] ?? '').trim().toLowerCase();
28
+
29
+ export const isInlineSafe = (contentType: string): boolean =>
30
+ INLINE_TYPES.has(essenceOf(contentType));
31
+
32
+ /**
33
+ * The headers a stored object is served with beyond its type. `attachment` makes a navigation to an
34
+ * uploaded `text/html` a download instead of a page on the app's origin; an embedding element
35
+ * (`<img>`, `<video>`) ignores it, so nothing that displays an object inline stops working. The
36
+ * `sandbox` policy is for the browser that renders it anyway: an opaque origin, no script, no
37
+ * cookies. `@ultimat3/http`'s security stage keeps it and adds the app's policy beside it.
38
+ */
39
+ export function storedObjectHeaders(contentType: string): Readonly<Record<string, string>> {
40
+ return isInlineSafe(contentType)
41
+ ? {}
42
+ : { 'content-disposition': 'attachment', 'content-security-policy': 'sandbox' };
43
+ }
package/src/sync-url.ts CHANGED
@@ -43,8 +43,8 @@ export function syncConnectSources(env: Readonly<Record<string, string | undefin
43
43
  }
44
44
 
45
45
  /**
46
- * The page origins a sync node admits besides its own host name: `APP_URL`'s, when set. Only a
47
- * deployment that serves the page on another host than the node needs it; an unparsable value is
46
+ * The page origins a sync node admits besides its own origin: `APP_URL`'s, when set. A deployment
47
+ * that serves the page on another port or host than the node sees needs it; an unparsable value is
48
48
  * refused rather than read as "no page origin", which would refuse every socket in silence.
49
49
  */
50
50
  export function syncOriginsFrom(env: Readonly<Record<string, string | undefined>>): string[] {
@@ -5,38 +5,12 @@
5
5
  import type { TaskDescriptor, TaskHandle } from '@ultimat3/jobs';
6
6
  import { getTask, registeredTasks } from '@ultimat3/jobs';
7
7
  import type { CronPhrases } from '@ultimat3/time';
8
- import {
9
- describeCron,
10
- fromEpochMs,
11
- nextCronOccurrenceMs,
12
- offsetLabel,
13
- toZoned,
14
- } from '@ultimat3/time';
8
+ import { describeCron, fromEpochMs, isoInZone, nextCronOccurrenceMs } from '@ultimat3/time';
15
9
  import { BadFlagError } from './errors';
16
10
 
17
11
  const DEFAULT_COUNT = 5;
18
12
  const MAX_COUNT = 50;
19
13
 
20
- const pad2 = (value: number): string => String(value).padStart(2, '0');
21
-
22
- /**
23
- * ISO-8601 rendered with the ZONE's OWN offset (`2026-03-08T03:00:00-04:00`), never collapsed to
24
- * UTC `Z` — an ambient zone is exactly the bug a task's `tz` exists to prevent. No single
25
- * "instant in a zone, as ISO" export exists in `@ultimat3/time` to call instead: `toIso` is
26
- * UTC-`Z` only, `isoDateInZone` is date-only, and `formatWithOffset` renders locale prose, not
27
- * ISO. So this composes the package's own instant→zoned-wall-clock conversion (`toZoned`) and
28
- * zone-label helper (`offsetLabel`) rather than a fresh `Intl.DateTimeFormat` call here.
29
- *
30
- * Exported for `cmd-tasks.ts`'s `last` column: the occurrence a task last fired and the one it
31
- * fires next are one clock, so they are one function.
32
- */
33
- export function isoInZone(ms: number, zone: string): string {
34
- const zoned = toZoned(fromEpochMs(ms), zone);
35
- const date = `${String(zoned.year).padStart(4, '0')}-${pad2(zoned.month)}-${pad2(zoned.day)}`;
36
- const time = `${pad2(zoned.hour)}:${pad2(zoned.minute)}:${pad2(zoned.second)}`;
37
- return `${date}T${time}${offsetLabel(zoned.offsetMinutes)}`;
38
- }
39
-
40
14
  /** `x tasks list` row: the descriptor plus the next occurrence, ms and rendered alike. */
41
15
  export interface TaskFact extends TaskDescriptor {
42
16
  readonly nextMs: number;
@@ -46,7 +20,7 @@ export interface TaskFact extends TaskDescriptor {
46
20
  function toFact(handle: TaskHandle, nowMs: number): TaskFact {
47
21
  const descriptor = handle.describe();
48
22
  const nextMs = nextCronOccurrenceMs(descriptor.cron, descriptor.tz, nowMs);
49
- return { ...descriptor, nextMs, next: isoInZone(nextMs, descriptor.tz) };
23
+ return { ...descriptor, nextMs, next: isoInZone(fromEpochMs(nextMs), descriptor.tz) };
50
24
  }
51
25
 
52
26
  export function listTaskFacts(nowMs: number): readonly TaskFact[] {
@@ -110,7 +84,7 @@ export function taskShowFacts(
110
84
  let cursor = nowMs;
111
85
  for (let i = 0; i < count; i += 1) {
112
86
  cursor = nextCronOccurrenceMs(descriptor.cron, descriptor.tz, cursor);
113
- upcoming.push({ ms: cursor, at: isoInZone(cursor, descriptor.tz) });
87
+ upcoming.push({ ms: cursor, at: isoInZone(fromEpochMs(cursor), descriptor.tz) });
114
88
  }
115
89
  return { descriptor, describe: describeCron(descriptor.cron, 'en-US', phrases), upcoming };
116
90
  }
@@ -82,6 +82,9 @@ export const ${name.camel} = mutator({
82
82
  input: t.object({ id: t.uuid, orgId: t.uuid, title: t.string }),
83
83
  output: t.object({ id: t.uuid, title: t.string }),
84
84
  policy: can${feature.pascal}Write,
85
+ // Required on a mutator (X_MUTATOR_NOT_IDEMPOTENT): the client replays this write under one
86
+ // Idempotency-Key after a dropped response, and the replay answers the first result.
87
+ idempotent: true,
85
88
  // No \`mcp\` until a real description is written: see \`x g action\`.
86
89
  // tx.table(name) rather than tx.${feature.plural}: the typed accessor exists only once the app
87
90
  // augments LocalTables, and generated code cannot assume that has happened yet. The name is the
@@ -77,6 +77,34 @@ export const plural = (input: string): string => {
77
77
  return `${input}s`;
78
78
  };
79
79
 
80
+ /** Singular words `plural()` already handles, though they end in a lone `s`. */
81
+ const SINGULAR_IN_S: ReadonlySet<string> = new Set([
82
+ 'alias',
83
+ 'atlas',
84
+ 'bias',
85
+ 'canvas',
86
+ 'gas',
87
+ 'lens',
88
+ ]);
89
+
90
+ /**
91
+ * The singular a plural name was made from, kebab-case — `undefined` when the name is not one
92
+ * `plural()` would have produced. Read off the last word only, `plural()`'s inverse: a name that
93
+ * is already plural is pluralised AGAIN by every generator that names a table (`posts` →
94
+ * `postses`), so it is refused with this spelling. `ss`, `us`, `is` endings are singular.
95
+ */
96
+ export const singularOf = (input: string): string | undefined => {
97
+ const parts = words(input);
98
+ const last = parts.at(-1);
99
+ if (last === undefined || !last.endsWith('s') || /(ss|us|is)$/.test(last)) return undefined;
100
+ if (SINGULAR_IN_S.has(last)) return undefined;
101
+ const candidates = last.endsWith('ies')
102
+ ? [`${last.slice(0, -3)}y`]
103
+ : [last.slice(0, -2), last.slice(0, -1)];
104
+ const found = candidates.find((candidate) => candidate.length > 0 && plural(candidate) === last);
105
+ return found === undefined ? undefined : [...parts.slice(0, -1), found].join('-');
106
+ };
107
+
80
108
  export const titleKey = (input: string): string => `app.${kebab(input)}.title`;
81
109
 
82
110
  /**