@ultimat3/cli 26.0.0 → 26.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "26.0.0",
3
+ "version": "26.1.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -39,33 +39,33 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@babel/core": "^8.0.1",
42
- "@ultimat3/action": "26.0.0",
43
- "@ultimat3/admin": "26.0.0",
44
- "@ultimat3/ai": "26.0.0",
45
- "@ultimat3/auth": "26.0.0",
46
- "@ultimat3/cache": "26.0.0",
47
- "@ultimat3/core": "26.0.0",
48
- "@ultimat3/db": "26.0.0",
49
- "@ultimat3/entity": "26.0.0",
50
- "@ultimat3/flags": "26.0.0",
51
- "@ultimat3/http": "26.0.0",
52
- "@ultimat3/i18n": "26.0.0",
53
- "@ultimat3/jobs": "26.0.0",
54
- "@ultimat3/mail": "26.0.0",
55
- "@ultimat3/manifest": "26.0.0",
56
- "@ultimat3/mcp": "26.0.0",
57
- "@ultimat3/money": "26.0.0",
58
- "@ultimat3/notify": "26.0.0",
59
- "@ultimat3/policy": "26.0.0",
60
- "@ultimat3/pwa": "26.0.0",
61
- "@ultimat3/query": "26.0.0",
62
- "@ultimat3/realtime": "26.0.0",
63
- "@ultimat3/render": "26.0.0",
64
- "@ultimat3/schema": "26.0.0",
65
- "@ultimat3/seo": "26.0.0",
66
- "@ultimat3/storage": "26.0.0",
67
- "@ultimat3/testing": "26.0.0",
68
- "@ultimat3/time": "26.0.0",
42
+ "@ultimat3/action": "26.1.0",
43
+ "@ultimat3/admin": "26.1.0",
44
+ "@ultimat3/ai": "26.1.0",
45
+ "@ultimat3/auth": "26.1.0",
46
+ "@ultimat3/cache": "26.1.0",
47
+ "@ultimat3/core": "26.1.0",
48
+ "@ultimat3/db": "26.1.0",
49
+ "@ultimat3/entity": "26.1.0",
50
+ "@ultimat3/flags": "26.1.0",
51
+ "@ultimat3/http": "26.1.0",
52
+ "@ultimat3/i18n": "26.1.0",
53
+ "@ultimat3/jobs": "26.1.0",
54
+ "@ultimat3/mail": "26.1.0",
55
+ "@ultimat3/manifest": "26.1.0",
56
+ "@ultimat3/mcp": "26.1.0",
57
+ "@ultimat3/money": "26.1.0",
58
+ "@ultimat3/notify": "26.1.0",
59
+ "@ultimat3/policy": "26.1.0",
60
+ "@ultimat3/pwa": "26.1.0",
61
+ "@ultimat3/query": "26.1.0",
62
+ "@ultimat3/realtime": "26.1.0",
63
+ "@ultimat3/render": "26.1.0",
64
+ "@ultimat3/schema": "26.1.0",
65
+ "@ultimat3/seo": "26.1.0",
66
+ "@ultimat3/storage": "26.1.0",
67
+ "@ultimat3/testing": "26.1.0",
68
+ "@ultimat3/time": "26.1.0",
69
69
  "babel-preset-solid": "^1.9.15"
70
70
  }
71
71
  }
package/src/app-env.ts CHANGED
@@ -5,10 +5,11 @@
5
5
  // why: Bun exposes no path API — the two files this module reads are joined to the app root.
6
6
  import { join } from 'node:path';
7
7
  import type { EnvSchema, EnvVarDecl } from '@ultimat3/core';
8
- import { ENV_EXAMPLE_PATH, ERROR_DOCS_URL, parseEnvKeys } from '@ultimat3/core';
9
- import { appConfigExport } from './app-config-load';
8
+ import { ENV_EXAMPLE_PATH, ERROR_DOCS_URL, parseEnvKeys, pushWired } from '@ultimat3/core';
9
+ import { appConfigExport, loadAppConfig } from './app-config-load';
10
10
  import { APP_CONFIG_FILE } from './app-root';
11
- import { appEnvExample, FRAMEWORK_SECRETS } from './framework-env';
11
+ import type { AppSecretFacts } from './framework-env';
12
+ import { appEnvExample, configOwes, FRAMEWORK_SECRETS } from './framework-env';
12
13
  import type { Finding } from './output';
13
14
  import { findingFrom } from './output';
14
15
 
@@ -53,7 +54,17 @@ export async function loadEnvSchema(root: string): Promise<EnvSchema | undefined
53
54
  * The bytes `.env.example` must hold: the app's declaration, then the framework's deploy-required
54
55
  * keys (`framework-env.ts`). Deterministic, so a rewrite that changes nothing diffs to nothing.
55
56
  */
56
- export const envExampleFor = (schema: EnvSchema): string => appEnvExample(schema);
57
+ export const envExampleFor = (schema: EnvSchema, app: AppSecretFacts): string =>
58
+ appEnvExample(schema, app);
59
+
60
+ /**
61
+ * What `app.config.ts` decides about the framework keys this app owes — `pwa.push` today, off the
62
+ * one loader, so the example, the drift gate and `x env check` read one answer.
63
+ */
64
+ export async function appSecretFacts(root: string): Promise<AppSecretFacts> {
65
+ const pwa = (await loadAppConfig(root))?.pwa;
66
+ return { push: pwa !== undefined && pushWired(pwa) };
67
+ }
57
68
 
58
69
  const driftFinding = (cause: string): Finding => ({
59
70
  code: 'X_ENV_EXAMPLE_DRIFT',
@@ -73,13 +84,17 @@ const driftFinding = (cause: string): Finding => ({
73
84
  */
74
85
  export async function envExampleFindings(root: string): Promise<readonly Finding[]> {
75
86
  let schema: EnvSchema | undefined;
87
+ let app: AppSecretFacts;
76
88
  try {
77
89
  schema = await loadEnvSchema(root);
90
+ // Inside the `try`: this read runs `defineConfig`, which `loadEnvSchema` does not — a config
91
+ // it refuses (`pwa.push` with no `vapid.subject`) is a finding here, never a crash of the step.
92
+ app = await appSecretFacts(root);
78
93
  } catch (error) {
79
94
  return [{ ...findingFrom(error), at: APP_CONFIG_FILE }];
80
95
  }
81
96
  if (schema === undefined) return [];
82
- const expected = envExampleFor(schema);
97
+ const expected = envExampleFor(schema, app);
83
98
  const file = Bun.file(join(root, ENV_EXAMPLE_PATH));
84
99
  if (!(await file.exists())) {
85
100
  return [
@@ -94,9 +109,9 @@ export async function envExampleFindings(root: string): Promise<readonly Finding
94
109
  const missing = Object.keys(schema).filter((key) => !present.has(key));
95
110
  // The framework's keys are owed by every deployed app, whatever its schema says (#679), and an
96
111
  // example written before they were part of the contract is missing exactly these.
97
- const owed = FRAMEWORK_SECRETS.map((secret) => secret.key).filter(
98
- (key) => !Object.hasOwn(schema, key) && !present.has(key),
99
- );
112
+ const owed = FRAMEWORK_SECRETS.filter((secret) => configOwes(secret, app))
113
+ .map((secret) => secret.key)
114
+ .filter((key) => !Object.hasOwn(schema, key) && !present.has(key));
100
115
  return [
101
116
  driftFinding(
102
117
  missing.length > 0
package/src/budgets.ts CHANGED
@@ -212,7 +212,8 @@ export async function readBuildStats(root: string): Promise<BuildStats | undefin
212
212
  export interface MeasureOptions {
213
213
  /**
214
214
  * Scripts a document names that the PROCESS serves and the static artifact does not carry, by
215
- * URL: the page boot (`/_x/page-boot/<id>.js`). Charged like any `<script src>` — ledger #28.
215
+ * URL: the page boot (`/_x/assets/page-boot/<id>.js`). Charged like any `<script src>` —
216
+ * ledger #28.
216
217
  */
217
218
  readonly served?: ReadonlyMap<string, string>;
218
219
  /**
package/src/cmd-env.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  maskedEnvValues,
12
12
  UltimateError,
13
13
  } from '@ultimat3/core';
14
- import { envExampleFor, loadEnvSchema } from './app-env';
14
+ import { appSecretFacts, envExampleFor, loadEnvSchema } from './app-env';
15
15
  import { requireAppRoot } from './app-root';
16
16
  import { envSpec } from './cmd-env-spec';
17
17
  import type { CliCommand, CommandContext } from './command';
@@ -51,7 +51,7 @@ async function requireSchema(cwd: string, subcommand: string) {
51
51
 
52
52
  async function writeExample(ctx: CommandContext): Promise<CommandResult> {
53
53
  const { root, schema } = await requireSchema(ctx.cwd, 'example');
54
- const contents = envExampleFor(schema);
54
+ const contents = envExampleFor(schema, await appSecretFacts(root));
55
55
  const path = join(root, ENV_EXAMPLE_PATH);
56
56
  const file = Bun.file(path);
57
57
  const fresh = (await file.exists()) && (await file.text()) === contents;
@@ -78,14 +78,14 @@ async function writeExample(ctx: CommandContext): Promise<CommandResult> {
78
78
  * the first deployed boot would, instead of each role crash-looping on one key at a time (#679).
79
79
  */
80
80
  async function checkProcessEnv(ctx: CommandContext): Promise<CommandResult> {
81
- const { schema } = await requireSchema(ctx.cwd, 'check');
81
+ const { root, schema } = await requireSchema(ctx.cwd, 'check');
82
82
  const report = checkEnv(schema, { env: ctx.env });
83
83
  const total = Object.keys(schema).length;
84
84
  // A key the app's own declaration already reported is one finding, not two.
85
85
  const reported = new Set(report.issues.map((issue) => issue.key));
86
86
  // Gated by `deployed()` inside — the boot's own rule, so a table naming no environment is
87
87
  // checked as the production process it boots as.
88
- const framework = frameworkSecretFindings(ctx.env, reported);
88
+ const framework = frameworkSecretFindings(ctx.env, reported, await appSecretFacts(root));
89
89
  const findings: readonly Finding[] = [
90
90
  ...report.issues.map((issue) => ({
91
91
  code: 'X_ENV_MISSING',
@@ -0,0 +1,17 @@
1
+ // `x vapid`'s declaration, apart from its body: the parser, `x help` and the `errors` step read it
2
+ // without loading `cmd-vapid.ts`, which `registry.ts` imports only when the command runs.
3
+
4
+ import type { CommandSpec } from './parse';
5
+
6
+ export const VAPID_SUBCOMMANDS = ['show', 'create'] as const;
7
+
8
+ export const vapidSpec: CommandSpec = {
9
+ name: 'vapid',
10
+ summary: 'the Web Push key pair: show which one this app signs with, or create and seal one',
11
+ usage: 'x vapid [show|create] [--json]',
12
+ requiresApp: true,
13
+ subcommands: [...VAPID_SUBCOMMANDS],
14
+ // The bare form reads; `create` writes the committed secrets file, so it is never the default.
15
+ defaultSubcommand: 'show',
16
+ flags: [],
17
+ };
@@ -0,0 +1,135 @@
1
+ // `x vapid` — the Web Push key pair (`pwa.push`). `create` mints a P-256 pair on WebCrypto and
2
+ // seals BOTH halves into `secrets.enc.json` in one write, so the two environment variables the
3
+ // boot reads can never come from different pairs; `show` names which pair this app signs with.
4
+ // The private key is never printed, in either renderer — `x secrets`' rule.
5
+
6
+ import type { MasterKeyRef, SecretValues } from '@ultimat3/core';
7
+ import {
8
+ isUltimateError,
9
+ readSecretsFile,
10
+ requireMasterKey,
11
+ SECRETS_FILE,
12
+ writeSecretsFile,
13
+ } from '@ultimat3/core';
14
+ import {
15
+ DEV_VAPID_KEYS,
16
+ generateVapidKeys,
17
+ VAPID_PRIVATE_KEY_ENV,
18
+ VAPID_PUBLIC_KEY_ENV,
19
+ } from '@ultimat3/pwa';
20
+ import { requireAppRoot } from './app-root';
21
+ import { SecretsExistsError } from './cmd-secrets';
22
+ import { vapidSpec } from './cmd-vapid-spec';
23
+ import type { CliCommand, CommandContext } from './command';
24
+ import { msg } from './messages';
25
+ import type { CommandResult } from './output';
26
+ import { recoverRotation } from './secrets-rotation';
27
+
28
+ /** The app's master key, as `x secrets` opens it — after finishing a rotation a crash interrupted. */
29
+ const secretsKeyFor = (root: string, env: CommandContext['env']): Promise<MasterKeyRef> =>
30
+ recoverRotation(root, requireMasterKey(root, env));
31
+
32
+ /**
33
+ * Both halves into the sealed file, or neither. A pair already there is refused: rotating a VAPID
34
+ * key unsubscribes every browser that subscribed with the old one, so it is a decision taken in
35
+ * `x secrets edit` (delete both lines, then create), never a side effect of re-running a command.
36
+ */
37
+ async function create(ctx: CommandContext): Promise<CommandResult> {
38
+ const root = requireAppRoot('vapid create', ctx.cwd).dir;
39
+ const key = await secretsKeyFor(root, ctx.env);
40
+ const before: SecretValues = await readSecretsFile(root, key);
41
+ if (before[VAPID_PUBLIC_KEY_ENV] !== undefined || before[VAPID_PRIVATE_KEY_ENV] !== undefined) {
42
+ throw new SecretsExistsError({
43
+ path: `${SECRETS_FILE} (${VAPID_PUBLIC_KEY_ENV}, ${VAPID_PRIVATE_KEY_ENV})`,
44
+ fix: `x secrets edit # delete both ${VAPID_PUBLIC_KEY_ENV} and ${VAPID_PRIVATE_KEY_ENV} first — every subscriber subscribes again after a new pair`,
45
+ });
46
+ }
47
+ const pair = await generateVapidKeys();
48
+ await writeSecretsFile(
49
+ root,
50
+ {
51
+ ...before,
52
+ [VAPID_PUBLIC_KEY_ENV]: pair.publicKey,
53
+ [VAPID_PRIVATE_KEY_ENV]: pair.privateKey,
54
+ },
55
+ key,
56
+ );
57
+ return {
58
+ ok: true,
59
+ command: 'vapid',
60
+ summary: msg('cli.vapid.created', { path: SECRETS_FILE }),
61
+ lines: [msg('cli.vapid.public', { key: pair.publicKey })],
62
+ data: {
63
+ path: SECRETS_FILE,
64
+ publicKey: pair.publicKey,
65
+ sealed: [VAPID_PUBLIC_KEY_ENV, VAPID_PRIVATE_KEY_ENV],
66
+ },
67
+ };
68
+ }
69
+
70
+ type Source = 'environment' | 'sealed' | 'development';
71
+
72
+ /** One literal key per source, so the catalog's every key is a string a reader can grep for. */
73
+ const SOURCE_SUMMARY = {
74
+ environment: 'cli.vapid.source.environment',
75
+ sealed: 'cli.vapid.source.sealed',
76
+ development: 'cli.vapid.source.development',
77
+ } as const satisfies Record<Source, string>;
78
+
79
+ /**
80
+ * The pair the boot would resolve, in its order: the environment (a deploy's, or `installSecrets()`
81
+ * having copied the sealed file into it), then the sealed file read directly, then the published
82
+ * development pair a local process falls back to.
83
+ */
84
+ async function show(ctx: CommandContext): Promise<CommandResult> {
85
+ const root = requireAppRoot('vapid show', ctx.cwd).dir;
86
+ const fromEnv = ctx.env[VAPID_PUBLIC_KEY_ENV]?.trim();
87
+ let source: Source = 'development';
88
+ let publicKey = DEV_VAPID_KEYS.publicKey;
89
+ let privateSet = false;
90
+ if (fromEnv !== undefined && fromEnv !== '') {
91
+ source = 'environment';
92
+ publicKey = fromEnv;
93
+ privateSet = (ctx.env[VAPID_PRIVATE_KEY_ENV] ?? '').trim() !== '';
94
+ } else {
95
+ const sealed = await readSealed(root, ctx);
96
+ const sealedKey = sealed?.[VAPID_PUBLIC_KEY_ENV];
97
+ if (sealed !== undefined && sealedKey !== undefined) {
98
+ source = 'sealed';
99
+ publicKey = sealedKey;
100
+ privateSet = sealed[VAPID_PRIVATE_KEY_ENV] !== undefined;
101
+ }
102
+ }
103
+ return {
104
+ ok: true,
105
+ command: 'vapid',
106
+ summary: msg(SOURCE_SUMMARY[source], { path: SECRETS_FILE }),
107
+ lines: [msg('cli.vapid.public', { key: publicKey })],
108
+ data: { source, publicKey, privateKey: privateSet || source === 'development' },
109
+ };
110
+ }
111
+
112
+ /** Absent, not broken: no secrets file, or no key to open one — the development pair is in force. */
113
+ const ABSENT: ReadonlySet<string> = new Set(['X_SECRETS_FILE_MISSING', 'X_SECRETS_KEY_MISSING']);
114
+
115
+ /**
116
+ * The sealed values, or `undefined` when this checkout has no secrets file or no key to open it.
117
+ * Every other refusal — a tampered file, a key for another file, a file that is not one, a rotation
118
+ * that cannot be finished — is thrown with its own fix: a sealed pair is THERE but unreadable, and
119
+ * reporting `development` would send the reader to `x vapid create`, which refuses the pair.
120
+ */
121
+ async function readSealed(root: string, ctx: CommandContext): Promise<SecretValues | undefined> {
122
+ try {
123
+ return await readSecretsFile(root, await secretsKeyFor(root, ctx.env));
124
+ } catch (error) {
125
+ if (isUltimateError(error) && ABSENT.has(error.code)) return undefined;
126
+ throw error;
127
+ }
128
+ }
129
+
130
+ export const vapidCommand: CliCommand = {
131
+ spec: vapidSpec,
132
+ async run(ctx: CommandContext): Promise<CommandResult> {
133
+ return (ctx.args.subcommand ?? 'show') === 'create' ? create(ctx) : show(ctx);
134
+ },
135
+ };
@@ -5,6 +5,7 @@
5
5
  import type { RealtimeConfig } from '@ultimat3/core';
6
6
  import type { RateLimitStore, Route } from '@ultimat3/http';
7
7
  import { appLocaleSet } from '@ultimat3/i18n/app-catalogs';
8
+ import { installedVapid } from '@ultimat3/pwa';
8
9
  import { describePages } from '@ultimat3/render';
9
10
  import type { IsrController } from '@ultimat3/render/server';
10
11
  import type { ImageTransformDriver } from '@ultimat3/seo';
@@ -122,6 +123,9 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
122
123
  ...sync.scripts,
123
124
  ...(navigation.script === undefined ? [] : [navigation.script]),
124
125
  ],
126
+ // The runtime `startServices` installed when `pwa.push` is on: the worker's push
127
+ // handler and the page's `x-push-key` come from the pair this process signs with.
128
+ vapid: installedVapid(),
125
129
  });
126
130
 
127
131
  // The app's own MCP endpoint, discovered from `apps/<app>/mcp.ts` and mounted through the SAME
@@ -14,6 +14,7 @@ import {
14
14
  tryResolveEnvironment,
15
15
  usesDevCursorSecret,
16
16
  } from '@ultimat3/core';
17
+ import { usesDevVapidKeys, VAPID_PRIVATE_KEY_ENV, VAPID_PUBLIC_KEY_ENV } from '@ultimat3/pwa';
17
18
  import { STORAGE_SIGNING_SECRET_KEY, usesDevStorageSecret } from '@ultimat3/storage';
18
19
  import type { Finding } from './output';
19
20
  import { storageIsExternal } from './runtime-bindings';
@@ -42,11 +43,24 @@ export const namedDeployed = (env: EnvTable): boolean => {
42
43
  return environment === 'staging' || environment === 'production';
43
44
  };
44
45
 
46
+ /**
47
+ * What the app's `app.config.ts` decides about which framework keys it owes — read by the caller,
48
+ * which has the root (`appSecretFacts`). `x doctor` asks of no config and passes `NO_APP_FACTS`.
49
+ */
50
+ export interface AppSecretFacts {
51
+ /** `pwa.enabled && pwa.push`: the boot resolves a VAPID pair and refuses without one. */
52
+ readonly push: boolean;
53
+ }
54
+
55
+ export const NO_APP_FACTS: AppSecretFacts = Object.freeze({ push: false });
56
+
45
57
  /** One framework secret: what refuses without it, when it is needed, and the command that mints one. */
46
58
  export interface FrameworkSecret {
47
59
  readonly key: string;
48
60
  /** The diagnostic code `x doctor` and `x env check` report it under. */
49
- readonly code: 'X_CURSOR_SECRET_DEV' | 'X_STORAGE_SECRET_DEV';
61
+ readonly code: 'X_CURSOR_SECRET_DEV' | 'X_STORAGE_SECRET_DEV' | 'X_PWA_VAPID_KEY_MISSING';
62
+ /** False for the one key here that is public (the VAPID public key). Default true. */
63
+ readonly secret?: boolean;
50
64
  /** Why the framework needs it, one line, rendered into `.env.example`. */
51
65
  readonly why: string;
52
66
  /** When the key is needed at all, if not always — rendered beside `why`. */
@@ -54,8 +68,14 @@ export interface FrameworkSecret {
54
68
  readonly cause: string;
55
69
  /** Pasted into a shell, so it is a literal command and never a paraphrase. */
56
70
  readonly fix: string;
57
- /** False when this deploy's shape never reads the key (object storage configured). */
58
- needed(env: EnvTable): boolean;
71
+ /**
72
+ * What `app.config.ts` must say for the key to be owed AT ALL — the VAPID pair, only with
73
+ * `pwa.push`. Absent: always. The ONE rule the example, the drift gate and `x env check` read
74
+ * (`configOwes`), so a second config-gated key is one line here, never two filters elsewhere.
75
+ */
76
+ readonly owedWhen?: (app: AppSecretFacts) => boolean;
77
+ /** False when this deploy's shape never reads the key (object storage configured, push off). */
78
+ needed(env: EnvTable, app: AppSecretFacts): boolean;
59
79
  /** True while the table would leave the process signing with the shipped development key. */
60
80
  unsafe(env: EnvTable): boolean;
61
81
  }
@@ -87,17 +107,50 @@ export const FRAMEWORK_SECRETS: readonly FrameworkSecret[] = [
87
107
  needed: (env) => !storageIsExternal(env),
88
108
  unsafe: (env) => usesDevStorageSecret({ env }),
89
109
  },
110
+ // The VAPID pair, owed only once `pwa.push` is on. Both halves are env (never config) and are
111
+ // sealed together by `x vapid create`, so the fix is that one command for either half.
112
+ {
113
+ key: VAPID_PUBLIC_KEY_ENV,
114
+ code: 'X_PWA_VAPID_KEY_MISSING',
115
+ secret: false,
116
+ why: 'Web Push public key, written into every page as <meta name="x-push-key">. Generate the pair with `x vapid create`.',
117
+ condition: 'Read only when pwa.push is true.',
118
+ cause: `${VAPID_PUBLIC_KEY_ENV} is unset, so a push-enabled deploy refuses to boot (X_PWA_VAPID_KEY_MISSING)`,
119
+ fix: 'x vapid create',
120
+ owedWhen: (app) => app.push,
121
+ needed: (_env, app) => app.push,
122
+ unsafe: (env) => (env[VAPID_PUBLIC_KEY_ENV] ?? '').trim() === '',
123
+ },
124
+ {
125
+ key: VAPID_PRIVATE_KEY_ENV,
126
+ code: 'X_PWA_VAPID_KEY_MISSING',
127
+ why: 'Signs every Web Push request (RFC 8292). Generate the pair with `x vapid create`.',
128
+ condition: 'Read only when pwa.push is true.',
129
+ cause: `${VAPID_PRIVATE_KEY_ENV} is unset or holds the published development key, so a push-enabled deploy refuses to boot`,
130
+ fix: 'x vapid create',
131
+ owedWhen: (app) => app.push,
132
+ needed: (_env, app) => app.push,
133
+ unsafe: (env) => usesDevVapidKeys(env),
134
+ },
90
135
  ];
91
136
 
137
+ /** Whether this app's config owes `secret` at all — `owedWhen`, else always. */
138
+ export const configOwes = (secret: FrameworkSecret, app: AppSecretFacts): boolean =>
139
+ secret.owedWhen?.(app) ?? true;
140
+
92
141
  /** The doctor/check finding for one secret — one wording per condition, wherever it is reported. */
93
142
  export function frameworkSecretFinding(secret: FrameworkSecret): Finding {
94
143
  return { code: secret.code, cause: secret.cause, fix: secret.fix, docs: ERROR_DOCS_URL };
95
144
  }
96
145
 
97
146
  /** Every secret this table's deploy shape needs and lacks, ungated. */
98
- const owedFindings = (env: EnvTable, skip: ReadonlySet<string>): readonly Finding[] =>
147
+ const owedFindings = (
148
+ env: EnvTable,
149
+ skip: ReadonlySet<string>,
150
+ app: AppSecretFacts,
151
+ ): readonly Finding[] =>
99
152
  FRAMEWORK_SECRETS.filter(
100
- (secret) => !skip.has(secret.key) && secret.needed(env) && secret.unsafe(env),
153
+ (secret) => !skip.has(secret.key) && secret.needed(env, app) && secret.unsafe(env),
101
154
  ).map((secret) => ({ ...frameworkSecretFinding(secret), at: ENV_EXAMPLE_PATH }));
102
155
 
103
156
  /**
@@ -109,13 +162,17 @@ const owedFindings = (env: EnvTable, skip: ReadonlySet<string>): readonly Findin
109
162
  export function frameworkSecretFindings(
110
163
  env: EnvTable,
111
164
  skip: ReadonlySet<string> = new Set(),
165
+ app: AppSecretFacts = NO_APP_FACTS,
112
166
  ): readonly Finding[] {
113
- return deployed(env) ? owedFindings(env, skip) : [];
167
+ return deployed(env) ? owedFindings(env, skip, app) : [];
114
168
  }
115
169
 
116
170
  /** `x doctor`'s: the same list and wording, gated by `namedDeployed` — this machine, as named. */
117
- export function machineSecretFindings(env: EnvTable): readonly Finding[] {
118
- return namedDeployed(env) ? owedFindings(env, new Set()) : [];
171
+ export function machineSecretFindings(
172
+ env: EnvTable,
173
+ app: AppSecretFacts = NO_APP_FACTS,
174
+ ): readonly Finding[] {
175
+ return namedDeployed(env) ? owedFindings(env, new Set(), app) : [];
119
176
  }
120
177
 
121
178
  /**
@@ -123,19 +180,39 @@ export function machineSecretFindings(env: EnvTable): readonly Finding[] {
123
180
  * declares is skipped: one key, one line, and the app's declaration is the one it validates.
124
181
  * Blank values only — these are secrets, and this file is committed.
125
182
  */
126
- function frameworkSection(schema: EnvSchema): string {
127
- const owed = FRAMEWORK_SECRETS.filter((secret) => !Object.hasOwn(schema, secret.key));
183
+ function frameworkSection(schema: EnvSchema, app: AppSecretFacts): string {
184
+ // `needed` against an empty table: the example names what a deploy of THIS app owes, so the
185
+ // config decides (push on or off) and no environment does — object storage is a deploy's choice,
186
+ // and the disk secret stays listed with its condition.
187
+ const owed = FRAMEWORK_SECRETS.filter(
188
+ (secret) => !Object.hasOwn(schema, secret.key) && configOwes(secret, app),
189
+ );
128
190
  if (owed.length === 0) return '';
191
+ // Each key names its own generator: `openssl` mints a hex secret, and a VAPID pair minted that
192
+ // way is one the boot refuses — only `x vapid create` makes a P-256 pair. `x doctor` asks of no
193
+ // config, so it never reports the pair; `x env check` reports every one.
194
+ const pair = owed.some((secret) => secret.owedWhen !== undefined);
129
195
  const lines = [
130
196
  '',
131
197
  '# --- Framework ---------------------------------------------------------------',
132
- '# Required outside development/test, whatever envSchema declares. Generate each with',
133
- '# `openssl rand -hex 32`; `x env check` and `x doctor` report one that is missing.',
198
+ ...(pair
199
+ ? [
200
+ '# Required outside development/test, whatever envSchema declares. Generate the hex',
201
+ '# secrets with `openssl rand -hex 32` and the VAPID pair with `x vapid create`;',
202
+ '# `x env check` reports one that is missing.',
203
+ ]
204
+ : [
205
+ '# Required outside development/test, whatever envSchema declares. Generate each with',
206
+ '# `openssl rand -hex 32`; `x env check` and `x doctor` report one that is missing.',
207
+ ]),
134
208
  ];
135
209
  for (const secret of owed) {
136
210
  lines.push('', `# ${secret.why}`);
137
211
  if (secret.condition !== undefined) lines.push(`# ${secret.condition}`);
138
- lines.push('# required when deployed · string · secret', `${secret.key}=`);
212
+ lines.push(
213
+ `# required when deployed · string${secret.secret === false ? '' : ' · secret'}`,
214
+ `${secret.key}=`,
215
+ );
139
216
  }
140
217
  return `${lines.join('\n')}\n`;
141
218
  }
@@ -144,5 +221,5 @@ function frameworkSection(schema: EnvSchema): string {
144
221
  * The bytes `.env.example` must hold: the app's declaration, then the framework's keys. The ONE
145
222
  * renderer `x env example`, the drift gate and `x new` share, so the three cannot disagree.
146
223
  */
147
- export const appEnvExample = (schema: EnvSchema): string =>
148
- `${renderEnvExample(schema)}${frameworkSection(schema)}`;
224
+ export const appEnvExample = (schema: EnvSchema, app: AppSecretFacts = NO_APP_FACTS): string =>
225
+ `${renderEnvExample(schema)}${frameworkSection(schema, app)}`;
@@ -17,6 +17,7 @@ import {
17
17
  SQL_NOTIFY_DIGESTS_TABLE,
18
18
  SQL_NOTIFY_INBOX_TABLE,
19
19
  } from '@ultimat3/notify';
20
+ import { PUSH_SUBSCRIPTIONS_TABLE, SQL_PUSH_SUBSCRIPTIONS_TABLE } from '@ultimat3/pwa/schema';
20
21
  import { FrameworkSchemaFailedError } from './schema-errors';
21
22
 
22
23
  export interface FrameworkSchema {
@@ -146,6 +147,17 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
146
147
  tables: Object.freeze([MCP_CONFIRMATIONS_TABLE]),
147
148
  ddl: Object.freeze([SQL_MCP_CONFIRMATIONS_TABLE]),
148
149
  }),
150
+ /**
151
+ * Web Push subscriptions — what `postgresPushSubscriptionStore()` reads and the boot installs
152
+ * once `pwa.push` is on. Applied whether or not it is, for every row's reason: turning push on
153
+ * must not be the deploy that discovers the table was never created. Through
154
+ * `@ultimat3/pwa/schema`, a leaf, for the admin and mcp rows' reason.
155
+ */
156
+ Object.freeze({
157
+ pkg: '@ultimat3/pwa',
158
+ tables: Object.freeze([PUSH_SUBSCRIPTIONS_TABLE]),
159
+ ddl: Object.freeze([SQL_PUSH_SUBSCRIPTIONS_TABLE]),
160
+ }),
149
161
  ]);
150
162
 
151
163
  /** Every relation this boot creates, flattened. */
package/src/messages.ts CHANGED
@@ -14,7 +14,7 @@ const CATALOG = {
14
14
  // A build-output line, so it belongs here rather than inline beside the emitter: `x build` is a
15
15
  // human surface and every other word it prints comes from this catalog.
16
16
  'cli.build.pushUnwired':
17
- 'pwa.push is true and no VAPID key is configured, so the emitted sw.js carries no push handler',
17
+ 'pwa.push is true and this build has no Web Push runtime behind it (a static export serves no subscribe action), so the emitted sw.js carries no push handler',
18
18
  // `describeCron`'s vocabulary. `@ultimat3/time` is tier 1 and reaches no i18n runtime, so the
19
19
  // caller supplies the words — and the caller here is a rendered `x tasks show` line, which is
20
20
  // exactly what this catalog holds. `msg()` leaves an un-supplied `{n}`/`{time}`/`{days}`/
@@ -355,6 +355,14 @@ const CATALOG = {
355
355
  'cli.secrets.edited': '{path} resealed — {added} added, {updated} changed, {removed} removed',
356
356
  'cli.secrets.unchanged': '{path} unchanged — nothing was written',
357
357
  'cli.secrets.set': 'sealed {name} into {path} — {count} secret(s)',
358
+ 'cli.vapid.created':
359
+ 'sealed a new Web Push key pair into {path} — ULTIMATE_VAPID_PUBLIC_KEY and ULTIMATE_VAPID_PRIVATE_KEY',
360
+ 'cli.vapid.public': ' public key (every page carries it as <meta name="x-push-key">): {key}',
361
+ 'cli.vapid.source.environment':
362
+ 'this process signs Web Push with the pair in its environment (ULTIMATE_VAPID_*)',
363
+ 'cli.vapid.source.sealed': 'this app signs Web Push with the pair sealed in {path}',
364
+ 'cli.vapid.source.development':
365
+ 'no pair in the environment or {path}: a local process signs with the published development pair, which a deployed boot refuses — x vapid create',
358
366
  'cli.secrets.rotated':
359
367
  'rotated {path} from master key {from} to {to} — {count} secret(s) resealed',
360
368
  'cli.secrets.retired':
@@ -140,7 +140,12 @@ export function navigationTagsOf(
140
140
  ): readonly HeadTag[] {
141
141
  if (entry.config.navigation === 'document') return [];
142
142
  const tags = navigationHeadFor(head, entry.surface);
143
- return tags === undefined ? [] : clientNavigationTags(tags);
143
+ if (tags === undefined) return [];
144
+ // A modal says so in its own document: the router shows it over the page beneath, never decided
145
+ // from a link — a redirect's target and a pasted `#/…` address are answered alike.
146
+ return clientNavigationTags(
147
+ entry.config.navigation === 'modal' ? { ...tags, modal: true } : tags,
148
+ );
144
149
  }
145
150
 
146
151
  /** `{ navigation }` for `metaOf`'s spread, or nothing. */
@@ -18,7 +18,7 @@
18
18
  // why: Bun exposes no path-join primitive, and the export writes each icon under an out directory.
19
19
  import { join } from 'node:path';
20
20
  import type { PwaColors, PwaOfflineConfig } from '@ultimat3/core';
21
- import { escapeHtml, localeSegment } from '@ultimat3/core';
21
+ import { escapeHtml, localeSegment, pushWired } from '@ultimat3/core';
22
22
  import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
23
23
  import { applyCacheHeaders } from '@ultimat3/http';
24
24
  import { appLocaleSet } from '@ultimat3/i18n/app-catalogs';
@@ -117,7 +117,7 @@ async function loadInstallable(root: string): Promise<InstallableApp | undefined
117
117
  locales: appLocales(await appLocaleSet(root)),
118
118
  offline: pwa.offline,
119
119
  backgroundSync: pwa.backgroundSync,
120
- push: pwa.push,
120
+ push: pushWired(pwa),
121
121
  };
122
122
  }
123
123
 
package/src/registry.ts CHANGED
@@ -37,6 +37,7 @@ import { secretsSpec } from './cmd-secrets-spec';
37
37
  import { shotSpec } from './cmd-shot-spec';
38
38
  import { tasksSpec } from './cmd-tasks-spec';
39
39
  import { testSpec } from './cmd-test-spec';
40
+ import { vapidSpec } from './cmd-vapid-spec';
40
41
  import { verifySpec } from './cmd-verify-spec';
41
42
  import type { CliCommand } from './command';
42
43
  import type { CommandSpec } from './parse';
@@ -87,6 +88,7 @@ export const LAZY_COMMANDS: readonly LazyCommand[] = [
87
88
  lazy(envSpec, async () => (await import('./cmd-env')).envCommand),
88
89
  lazy(secretsSpec, async () => (await import('./cmd-secrets')).secretsCommand),
89
90
  lazy(authSpec, async () => (await import('./cmd-auth')).authCommand),
91
+ lazy(vapidSpec, async () => (await import('./cmd-vapid')).vapidCommand),
90
92
  lazy(manifestSpec, async () => (await import('./cmd-manifest')).manifestCommand),
91
93
  lazy(routesSpec, async () => (await import('./cmd-routes')).routesCommand),
92
94
  lazy(actionsSpec, async () => (await import('./cmd-registries')).actionsCommand),
@@ -0,0 +1,80 @@
1
+ // Web Push at boot: `pwa.push` read off the one config, the VAPID pair resolved from the boot's
2
+ // environment (refused before any service starts), and the runtime installed over the boot's own
3
+ // executor — so `pushSubscribe()`, `pushUnsubscribe()` and `webPush()` work in every role with no
4
+ // line of app wiring, and a deploy with no keys never boots.
5
+ //
6
+ // `@ultimat3/pwa` behind `await import()`: an app with push off — most of them — loads none of it
7
+ // into a worker or a migrate pod (`serve-graph.test.ts`), exactly as the nats client is opened.
8
+
9
+ import type { AppConfig, PgExecutor } from '@ultimat3/core';
10
+ import { logger, PWA_PUSH_FIX, pushWired } from '@ultimat3/core';
11
+ import type { ResolvedVapidKeys } from '@ultimat3/pwa';
12
+ import type { Env } from './runtime-bindings';
13
+
14
+ /** `pwa.vapid.subject` when this app's boot owes a push runtime, else `undefined`. */
15
+ export function pushSubjectOf(config: AppConfig | undefined): string | undefined {
16
+ const pwa = config?.pwa;
17
+ if (pwa === undefined || !pushWired(pwa)) return undefined;
18
+ // `pushWired` checked the block is there; `defineConfig` checked its subject is one a push
19
+ // service takes (`pwaPushIssues`).
20
+ return pwa.vapid?.subject;
21
+ }
22
+
23
+ export interface PushSelection {
24
+ readonly subject: string;
25
+ readonly resolved: ResolvedVapidKeys;
26
+ /** `pwa.vapid.pushHosts`: push services beyond the built-in list. */
27
+ readonly pushHosts: readonly string[];
28
+ }
29
+
30
+ /**
31
+ * Pure apart from the key check — reads env, dials nothing — so it runs BEFORE the queue, beside
32
+ * the mail driver's selection: a deploy missing `ULTIMATE_VAPID_PRIVATE_KEY` fails on the spot
33
+ * with `X_PWA_VAPID_KEY_MISSING`, not after PGlite started, and never on the first push nobody got.
34
+ */
35
+ export async function selectWebPush(
36
+ config: AppConfig | undefined,
37
+ env: Env,
38
+ ): Promise<PushSelection | undefined> {
39
+ const subject = pushSubjectOf(config);
40
+ if (subject === undefined) {
41
+ const pwa = config?.pwa;
42
+ // Accepted for 26.0.0's sake, and never silent: the app asked for push and gets none.
43
+ if (pwa?.enabled === true && pwa.push) {
44
+ logger.warn('pwa.push unwired', {
45
+ cause:
46
+ 'pwa.push is true and pwa.vapid is unset, so no push runtime, handler or key is wired',
47
+ fix: PWA_PUSH_FIX,
48
+ });
49
+ }
50
+ return undefined;
51
+ }
52
+ const { resolveVapidKeys } = await import('@ultimat3/pwa');
53
+ return {
54
+ subject,
55
+ resolved: await resolveVapidKeys(env),
56
+ pushHosts: config?.pwa.vapid?.pushHosts ?? [],
57
+ };
58
+ }
59
+
60
+ /** Install the runtime over the boot's executor; answers its release. */
61
+ export async function installAppWebPush(
62
+ selection: PushSelection,
63
+ executor: PgExecutor,
64
+ ): Promise<() => void> {
65
+ const { installWebPush, postgresPushSubscriptionStore } = await import('@ultimat3/pwa');
66
+ if (selection.resolved.source === 'development') {
67
+ // Said once per boot: a reader seeing pushes arrive locally must know they are signed with a
68
+ // pair printed in the framework's source, which no deploy accepts.
69
+ logger.info('pwa.push development keys', {
70
+ cause: 'ULTIMATE_VAPID_PUBLIC_KEY and ULTIMATE_VAPID_PRIVATE_KEY are unset',
71
+ fix: 'x vapid create # before the first deploy; a deployed boot refuses the development pair',
72
+ });
73
+ }
74
+ return installWebPush({
75
+ store: postgresPushSubscriptionStore({ executor }),
76
+ keys: selection.resolved.keys,
77
+ subject: selection.subject,
78
+ pushHosts: selection.pushHosts,
79
+ });
80
+ }
@@ -40,6 +40,7 @@ import { selectAppMailDriver } from './runtime-mail';
40
40
  import { inboxRetentionOf } from './runtime-notify-retention';
41
41
  import type { RuntimeOverrides } from './runtime-overrides';
42
42
  import { installRetentionSweep } from './runtime-purge';
43
+ import { installAppWebPush, selectWebPush } from './runtime-push';
43
44
  import type { DevDbClient, SchemaMode } from './runtime-queue';
44
45
  import { startQueue } from './runtime-queue';
45
46
  import { realtimeConfigOf } from './runtime-realtime';
@@ -342,6 +343,9 @@ export async function startServices(
342
343
  // `ROLE=sync` container makes, so this process cannot resolve the bus differently from the
343
344
  // container it stands in for.
344
345
  const realtime = realtimeConfigOf(config);
346
+ // Fourth: `pwa.push` and its VAPID pair (`runtime-push.ts`). Resolving the pair reads env and
347
+ // signs one probe — a deploy with no keys, or two halves of different pairs, refuses here.
348
+ const push = await selectWebPush(config, env);
345
349
  const workerConfig = workerConfigOf(config, env);
346
350
  const bus: TransportSelection = selectTransport(env, realtime);
347
351
  // `env`, not the ambient one: this function is HANDED the boot's environment and every other
@@ -419,6 +423,9 @@ export async function startServices(
419
423
  const connectable = transport;
420
424
  started.push(registerReadinessCheck('transport', () => connectable.connected));
421
425
  }
426
+ // Before `loadApp` for the auth limiters' reason: `pushSubscribe()` is declared when the app's
427
+ // modules import, and reads this runtime per call.
428
+ if (push !== undefined) started.push(await installAppWebPush(push, executor));
422
429
  const storage = startStorage(services, env, overrides?.storage);
423
430
  // With no credential this is the memory driver: caught, not sent, so the `/_x` mail panel can
424
431
  // show what a template renders in every locale without a mailbox or a message escaping to a
package/src/serve-web.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import type { Route } from '@ultimat3/http';
7
7
  import { appLocaleSet } from '@ultimat3/i18n/app-catalogs';
8
8
 
9
+ import { installedVapid } from '@ultimat3/pwa';
9
10
  import { describePages } from '@ultimat3/render';
10
11
  import { apiMountRoutes, apiRoutes, pagePostRoutes } from './api-routes';
11
12
  import { loadSignInPath } from './app-auth';
@@ -96,6 +97,9 @@ export async function webSurface(
96
97
  ...sync.scripts,
97
98
  ...(navigation.script === undefined ? [] : [navigation.script]),
98
99
  ],
100
+ // The runtime `startServices` installed when `pwa.push` is on: the worker's push
101
+ // handler and the page's `x-push-key` come from the pair this process signs with.
102
+ vapid: installedVapid(),
99
103
  });
100
104
  // The app's own MCP endpoint, through the same call `x dev` makes — see `app-mcp.ts`.
101
105
  const mcpMount = await mountAppMcp(options.root);
@@ -3,8 +3,9 @@
3
3
  // until #390. Beside `pwa-artifacts.ts` and not inside it: that file needs a root and a config
4
4
  // file, this one needs a booted app and a finished build.
5
5
 
6
- import type { PrecacheManifest } from '@ultimat3/pwa';
7
- import { generateServiceWorker } from '@ultimat3/pwa';
6
+ import { escapeHtml } from '@ultimat3/core';
7
+ import type { PrecacheManifest, VapidConfig } from '@ultimat3/pwa';
8
+ import { generateServiceWorker, PUSH_KEY_META } from '@ultimat3/pwa';
8
9
  import type { RouteDescriptor } from '@ultimat3/render';
9
10
  import type { IslandBundle } from './island-bundle';
10
11
  import { ISLAND_BASE_PATH } from './island-bundle';
@@ -72,6 +73,12 @@ export interface ServiceWorkerInput {
72
73
  * offline reload that cannot load the boot restores no record and shows the old count.
73
74
  */
74
75
  readonly scripts?: readonly { readonly url: string; readonly bytes: number }[];
76
+ /**
77
+ * The installed Web Push runtime's public key and subject (`@ultimat3/pwa`'s `installedVapid()`),
78
+ * which the boot put there from `pwa.push` and the VAPID pair — or absent where no runtime runs:
79
+ * a static export serves no subscribe action, so its worker carries no push handler.
80
+ */
81
+ readonly vapid?: VapidConfig | undefined;
75
82
  }
76
83
 
77
84
  /**
@@ -175,6 +182,7 @@ export function serviceWorkerArtifacts(
175
182
  personalPages: pwa.offline.personalPages,
176
183
  },
177
184
  capabilities: { backgroundSync: pwa.backgroundSync, push: pwa.push },
185
+ ...(pwa.push && input.vapid !== undefined ? { vapid: input.vapid } : {}),
178
186
  assets: precacheAssets({
179
187
  routes: input.routes,
180
188
  documents,
@@ -194,15 +202,14 @@ export function serviceWorkerArtifacts(
194
202
  return {
195
203
  source: output.source,
196
204
  register: serviceWorkerRegistration(),
197
- head,
205
+ head: head + pushKeyMeta(pwa, input.vapid),
198
206
  precache: output.precache,
199
207
  // `output.warnings` IS `output.precache.warnings` — the generator returns the manifest's list
200
208
  // verbatim — so it is read once, not twice. The push line is this module's own, and it is the
201
209
  // one thing the generator cannot say: `generateServiceWorker` emits a push handler only when a
202
- // VAPID key comes with the capability, and drops it in SILENCE otherwise. `pwa.push: true` in
203
- // an `app.config.ts` therefore wires nothing and reports nothing, which is `jobs.driver`'s
204
- // shape one package over.
205
- warnings: [...output.warnings, ...pushWarning(pwa)],
210
+ // VAPID key comes with the capability, and drops it in SILENCE otherwise — which, since the
211
+ // boot installs the runtime whenever `pwa.push` is on, is a build with no runtime behind it.
212
+ warnings: [...output.warnings, ...pushWarning(pwa, input.vapid)],
206
213
  };
207
214
  }
208
215
 
@@ -219,10 +226,20 @@ export const serviceWorkerHead = (pwa: PwaArtifacts): string | undefined =>
219
226
  pwa.offline.fallback === null ? undefined : `<script src="${SW_REGISTER_PATH}" defer></script>`;
220
227
 
221
228
  /**
222
- * `pwa.push: true` with nothing to sign a subscription with. There is no `pwa.vapid` config key
223
- * yet, so today this fires for EVERY app that sets the flag — deliberately: a switch that silently
224
- * does nothing is the defect this framework keeps re-shipping, and a warning naming the missing
225
- * half is the smallest honest answer until the key exists.
229
+ * `pwa.push: true` in a build no Web Push runtime stands behind. A served boot installs one whenever
230
+ * the flag is on (`runtime-push.ts`, and it refuses to boot without the keys), so this fires for a
231
+ * static export alone — which serves no subscribe action, so a push handler there would be a
232
+ * capability the deploy cannot use. Said, never dropped in silence.
233
+ */
234
+ const pushWarning = (pwa: PwaArtifacts, vapid: VapidConfig | undefined): readonly string[] =>
235
+ pwa.push && vapid === undefined ? [msg('cli.build.pushUnwired')] : [];
236
+
237
+ /**
238
+ * `<meta name="x-push-key">`: the public key `@ultimat3/pwa/client`'s `subscribeToPush` hands the
239
+ * browser. In the document rather than fetched, so subscribing is one click and no round trip, and
240
+ * a key rotation reaches every page on its next render. Public by definition; escaped regardless.
226
241
  */
227
- const pushWarning = (pwa: PwaArtifacts): readonly string[] =>
228
- pwa.push ? [msg('cli.build.pushUnwired')] : [];
242
+ const pushKeyMeta = (pwa: PwaArtifacts, vapid: VapidConfig | undefined): string =>
243
+ pwa.push && vapid !== undefined
244
+ ? `<meta name="${PUSH_KEY_META}" content="${escapeHtml(vapid.publicKey)}">`
245
+ : '';
package/src/sync-url.ts CHANGED
@@ -11,6 +11,15 @@ import { ConfigInvalidError } from '@ultimat3/core';
11
11
  */
12
12
  export const SYNC_PATH = '/_x/sync';
13
13
 
14
+ /**
15
+ * Exactly what a proxy routes to the `sync` role: the socket's path, and anything under it. No
16
+ * other framework route matches it — scripts live under `FRAMEWORK_ASSET_BASE_PATH`
17
+ * (`/_x/assets/…`) — so an operator's ordinary prefix rule can never swallow a page's asset.
18
+ * `wiki/Deployment.md` prints this pattern; `worker-bundle.test.ts` holds every framework route
19
+ * outside it.
20
+ */
21
+ export const SYNC_PROXY_PATTERN = /^\/_x\/sync(\/.*)?$/;
22
+
14
23
  /**
15
24
  * `SYNC_URL` verbatim when the deployment states one — the Compose rung publishes `sync` on its
16
25
  * own port with no proxy in front, so only the deployment knows that URL — else `SYNC_PATH`,
@@ -7,24 +7,34 @@
7
7
  // why: Bun ships no path API; the entry is resolved to a file and named in the cause.
8
8
  import { dirname, join, relative } from 'node:path';
9
9
  import type { Route, UltimateRequest } from '@ultimat3/http';
10
- import { applyCacheHeaders, jsonResponse } from '@ultimat3/http';
10
+ import { applyCacheHeaders, jsonResponse, NO_STORE } from '@ultimat3/http';
11
11
  import { FrameworkScriptBuildFailedError, type FrameworkScriptKind } from './errors';
12
12
  import { describeBuildError, graphHash, stripDebugId } from './island-identity';
13
13
 
14
- /** Under the dev namespace `/_x` — where the socket it opens (`/_x/sync`) already lives. */
15
- export const SYNC_WORKER_BASE_PATH = '/_x/sync-worker';
14
+ /**
15
+ * The ONE prefix every framework-served, content-addressed script lives under — the sync worker,
16
+ * the page boot, the client router — the way Rails serves `/assets/<name>-<sha>`. No live endpoint
17
+ * shares it: the worker lived at `/_x/sync-worker/…`, and an operator's ordinary prefix rule for
18
+ * the socket (`^/_x/sync` → the sync role) sent it to a process that serves only the socket, so
19
+ * no page got its worker and the CDN kept the 404 for hours. `SYNC_PROXY_PATTERN` (`sync-url.ts`)
20
+ * is the socket's whole claim, and `worker-bundle.test.ts` holds the two apart.
21
+ */
22
+ export const FRAMEWORK_ASSET_BASE_PATH = '/_x/assets';
23
+
24
+ /** The sync worker: `<FRAMEWORK_ASSET_BASE_PATH>/sync-worker/<hash>.js`. */
25
+ export const SYNC_WORKER_BASE_PATH = `${FRAMEWORK_ASSET_BASE_PATH}/sync-worker`;
16
26
 
17
27
  /** What an app resolves: realtime's worker entry, from the APP's install, never the CLI's own. */
18
28
  export const SYNC_WORKER_SPECIFIER = '@ultimat3/realtime/sync-worker';
19
29
 
20
- /** Under the dev namespace too; one per document that carries a principal scope. */
21
- export const PAGE_BOOT_BASE_PATH = '/_x/page-boot';
30
+ /** The page boot, one per document that carries a principal scope. */
31
+ export const PAGE_BOOT_BASE_PATH = `${FRAMEWORK_ASSET_BASE_PATH}/page-boot`;
22
32
 
23
33
  /** Realtime's page boot, from the APP's install. */
24
34
  export const PAGE_BOOT_SPECIFIER = '@ultimat3/realtime/boot';
25
35
 
26
- /** Under the dev namespace too; one per document whose surface opted into client navigation. */
27
- export const NAVIGATION_BASE_PATH = '/_x/navigation';
36
+ /** The client router, one per document whose surface opted into client navigation. */
37
+ export const NAVIGATION_BASE_PATH = `${FRAMEWORK_ASSET_BASE_PATH}/navigation`;
28
38
 
29
39
  /** Render's client router, from the APP's install — the copy its documents were rendered by. */
30
40
  export const NAVIGATION_SPECIFIER = '@ultimat3/render/navigation';
@@ -198,7 +208,7 @@ function scriptRoute(
198
208
  handler: (request: UltimateRequest): Response => {
199
209
  const script = source();
200
210
  if (script === undefined || script.url !== request.pathname) {
201
- return jsonResponse(
211
+ const missing = jsonResponse(
202
212
  {
203
213
  ok: false,
204
214
  error: {
@@ -209,6 +219,9 @@ function scriptRoute(
209
219
  },
210
220
  { status: 404 },
211
221
  );
222
+ // `no-store`, said here and not left to the pipeline: a miss on a content-addressed URL
223
+ // must never be pinned by a CDN or a proxy (`island-routes.ts`' rule).
224
+ return applyCacheHeaders(missing, NO_STORE);
212
225
  }
213
226
  return applyCacheHeaders(
214
227
  new Response(script.code, { headers: { 'content-type': 'text/javascript' } }),