@ultimat3/cli 26.0.0 → 26.1.1
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 +28 -28
- package/src/app-env.ts +23 -8
- package/src/budgets.ts +2 -1
- package/src/cmd-env.ts +4 -4
- package/src/cmd-vapid-spec.ts +17 -0
- package/src/cmd-vapid.ts +135 -0
- package/src/dev-route-table.ts +4 -0
- package/src/framework-env.ts +92 -15
- package/src/framework-schema.ts +12 -0
- package/src/messages.ts +9 -1
- package/src/page-navigation.ts +6 -1
- package/src/pwa-artifacts.ts +2 -2
- package/src/registry.ts +2 -0
- package/src/runtime-push.ts +80 -0
- package/src/runtime-services.ts +7 -0
- package/src/serve-web.ts +4 -0
- package/src/sw-artifacts.ts +30 -13
- package/src/sync-url.ts +9 -0
- package/src/worker-bundle.ts +21 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "26.
|
|
3
|
+
"version": "26.1.1",
|
|
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.
|
|
43
|
-
"@ultimat3/admin": "26.
|
|
44
|
-
"@ultimat3/ai": "26.
|
|
45
|
-
"@ultimat3/auth": "26.
|
|
46
|
-
"@ultimat3/cache": "26.
|
|
47
|
-
"@ultimat3/core": "26.
|
|
48
|
-
"@ultimat3/db": "26.
|
|
49
|
-
"@ultimat3/entity": "26.
|
|
50
|
-
"@ultimat3/flags": "26.
|
|
51
|
-
"@ultimat3/http": "26.
|
|
52
|
-
"@ultimat3/i18n": "26.
|
|
53
|
-
"@ultimat3/jobs": "26.
|
|
54
|
-
"@ultimat3/mail": "26.
|
|
55
|
-
"@ultimat3/manifest": "26.
|
|
56
|
-
"@ultimat3/mcp": "26.
|
|
57
|
-
"@ultimat3/money": "26.
|
|
58
|
-
"@ultimat3/notify": "26.
|
|
59
|
-
"@ultimat3/policy": "26.
|
|
60
|
-
"@ultimat3/pwa": "26.
|
|
61
|
-
"@ultimat3/query": "26.
|
|
62
|
-
"@ultimat3/realtime": "26.
|
|
63
|
-
"@ultimat3/render": "26.
|
|
64
|
-
"@ultimat3/schema": "26.
|
|
65
|
-
"@ultimat3/seo": "26.
|
|
66
|
-
"@ultimat3/storage": "26.
|
|
67
|
-
"@ultimat3/testing": "26.
|
|
68
|
-
"@ultimat3/time": "26.
|
|
42
|
+
"@ultimat3/action": "26.1.1",
|
|
43
|
+
"@ultimat3/admin": "26.1.1",
|
|
44
|
+
"@ultimat3/ai": "26.1.1",
|
|
45
|
+
"@ultimat3/auth": "26.1.1",
|
|
46
|
+
"@ultimat3/cache": "26.1.1",
|
|
47
|
+
"@ultimat3/core": "26.1.1",
|
|
48
|
+
"@ultimat3/db": "26.1.1",
|
|
49
|
+
"@ultimat3/entity": "26.1.1",
|
|
50
|
+
"@ultimat3/flags": "26.1.1",
|
|
51
|
+
"@ultimat3/http": "26.1.1",
|
|
52
|
+
"@ultimat3/i18n": "26.1.1",
|
|
53
|
+
"@ultimat3/jobs": "26.1.1",
|
|
54
|
+
"@ultimat3/mail": "26.1.1",
|
|
55
|
+
"@ultimat3/manifest": "26.1.1",
|
|
56
|
+
"@ultimat3/mcp": "26.1.1",
|
|
57
|
+
"@ultimat3/money": "26.1.1",
|
|
58
|
+
"@ultimat3/notify": "26.1.1",
|
|
59
|
+
"@ultimat3/policy": "26.1.1",
|
|
60
|
+
"@ultimat3/pwa": "26.1.1",
|
|
61
|
+
"@ultimat3/query": "26.1.1",
|
|
62
|
+
"@ultimat3/realtime": "26.1.1",
|
|
63
|
+
"@ultimat3/render": "26.1.1",
|
|
64
|
+
"@ultimat3/schema": "26.1.1",
|
|
65
|
+
"@ultimat3/seo": "26.1.1",
|
|
66
|
+
"@ultimat3/storage": "26.1.1",
|
|
67
|
+
"@ultimat3/testing": "26.1.1",
|
|
68
|
+
"@ultimat3/time": "26.1.1",
|
|
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 {
|
|
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 =>
|
|
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.
|
|
98
|
-
(
|
|
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>` —
|
|
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
|
+
};
|
package/src/cmd-vapid.ts
ADDED
|
@@ -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
|
+
};
|
package/src/dev-route-table.ts
CHANGED
|
@@ -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
|
package/src/framework-env.ts
CHANGED
|
@@ -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
|
-
/**
|
|
58
|
-
|
|
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 = (
|
|
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(
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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(
|
|
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)}`;
|
package/src/framework-schema.ts
CHANGED
|
@@ -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
|
|
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':
|
package/src/page-navigation.ts
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/src/pwa-artifacts.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/runtime-services.ts
CHANGED
|
@@ -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);
|
package/src/sw-artifacts.ts
CHANGED
|
@@ -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
|
|
7
|
-
import {
|
|
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
|
|
203
|
-
//
|
|
204
|
-
|
|
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`
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
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
|
|
228
|
-
pwa.push
|
|
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`,
|
package/src/worker-bundle.ts
CHANGED
|
@@ -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
|
-
/**
|
|
15
|
-
|
|
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
|
-
/**
|
|
21
|
-
export const PAGE_BOOT_BASE_PATH =
|
|
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
|
-
/**
|
|
27
|
-
export const NAVIGATION_BASE_PATH =
|
|
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
|
-
|
|
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' } }),
|