@ultimat3/core 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 +2 -2
- package/src/config-keys.ts +3 -0
- package/src/config-pwa.ts +114 -0
- package/src/config.ts +2 -1
- package/src/index.ts +8 -1
- package/src/registrar.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/core",
|
|
3
|
-
"version": "26.
|
|
3
|
+
"version": "26.1.1",
|
|
4
4
|
"description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,6 +37,6 @@
|
|
|
37
37
|
"test": "bun test"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@ultimat3/schema": "26.
|
|
40
|
+
"@ultimat3/schema": "26.1.1"
|
|
41
41
|
}
|
|
42
42
|
}
|
package/src/config-keys.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
PwaSchemeColors,
|
|
13
13
|
PwaScreenshot,
|
|
14
14
|
PwaShortcut,
|
|
15
|
+
PwaVapidConfig,
|
|
15
16
|
} from './config-pwa';
|
|
16
17
|
import { ConfigInvalidError } from './errors';
|
|
17
18
|
import { isJsonObject } from './json-object';
|
|
@@ -42,6 +43,7 @@ export const SECTION_KEYS: Readonly<Record<string, readonly string[]>> = Object.
|
|
|
42
43
|
'offline',
|
|
43
44
|
'backgroundSync',
|
|
44
45
|
'push',
|
|
46
|
+
'vapid',
|
|
45
47
|
'name',
|
|
46
48
|
'colors',
|
|
47
49
|
'id',
|
|
@@ -50,6 +52,7 @@ export const SECTION_KEYS: Readonly<Record<string, readonly string[]>> = Object.
|
|
|
50
52
|
'shortcuts',
|
|
51
53
|
'screenshots',
|
|
52
54
|
]),
|
|
55
|
+
'pwa.vapid': keysOf<PwaVapidConfig>()(['subject', 'pushHosts']),
|
|
53
56
|
'pwa.colors': keysOf<PwaColors>()(['light', 'dark']),
|
|
54
57
|
'pwa.colors.light': keysOf<PwaSchemeColors>()(['themeColor', 'backgroundColor']),
|
|
55
58
|
'pwa.colors.dark': keysOf<PwaSchemeColors>()(['themeColor', 'backgroundColor']),
|
package/src/config-pwa.ts
CHANGED
|
@@ -45,11 +45,39 @@ export interface PwaOfflineConfig {
|
|
|
45
45
|
readonly personalPages: 'never' | 'last-member';
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Web Push's one piece of configuration that is not a secret. The KEYS are not here: both halves
|
|
50
|
+
* of the VAPID pair are environment variables (`ULTIMATE_VAPID_PUBLIC_KEY`,
|
|
51
|
+
* `ULTIMATE_VAPID_PRIVATE_KEY`, sealed together by `x vapid create`), because a public key in
|
|
52
|
+
* committed config and a private key per deploy are two places that can disagree — and the
|
|
53
|
+
* disagreement is a 403 from every push service on every send.
|
|
54
|
+
*/
|
|
55
|
+
export interface PwaVapidConfig {
|
|
56
|
+
/**
|
|
57
|
+
* `mailto:` or an `https:` URL: who a push service writes to about this server (RFC 8292 §2.1).
|
|
58
|
+
* Every service requires it; Apple's refuses a token without one.
|
|
59
|
+
*/
|
|
60
|
+
readonly subject: string;
|
|
61
|
+
/**
|
|
62
|
+
* Push services this app sends to BEYOND the built-in ones (`@ultimat3/pwa`'s
|
|
63
|
+
* `PUSH_SERVICE_HOSTS`: FCM, Mozilla, Apple, WNS). Host names, each admitting its subdomains —
|
|
64
|
+
* a self-hosted autopush, a test's stub. An endpoint on any other host is refused when it is
|
|
65
|
+
* stored and never dialled (`X_PWA_PUSH_HOST_UNLISTED`): it came from a request body.
|
|
66
|
+
*/
|
|
67
|
+
readonly pushHosts?: readonly string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
48
70
|
export interface PwaConfig {
|
|
49
71
|
readonly enabled: boolean;
|
|
50
72
|
readonly offline: PwaOfflineConfig;
|
|
51
73
|
readonly backgroundSync: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Web Push: the `push` and `notificationclick` handlers in `sw.js`, the subscription runtime the
|
|
76
|
+
* boot installs, and `<meta name="x-push-key">` on every document. Requires `vapid`.
|
|
77
|
+
*/
|
|
52
78
|
readonly push: boolean;
|
|
79
|
+
/** Required once `push` is true, refused while it is false (a key with no reader). */
|
|
80
|
+
readonly vapid?: PwaVapidConfig;
|
|
53
81
|
/**
|
|
54
82
|
* The install title, and the one manifest member nothing can derive. `AppConfig.name` is a slug
|
|
55
83
|
* (`^[a-z][a-z0-9-]{1,63}$`) and an install prompt shows a person a title, so `ledger-demo` is
|
|
@@ -217,6 +245,92 @@ export function pwaIssues(pwa: PwaConfig, issues: string[]): boolean {
|
|
|
217
245
|
return issues.length > before;
|
|
218
246
|
}
|
|
219
247
|
|
|
248
|
+
/** Appended only when the push half of the block is what failed. */
|
|
249
|
+
export const PWA_PUSH_FIX =
|
|
250
|
+
"set pwa.vapid: { subject: 'mailto:ops@example.com' } beside pwa.push: true in app.config.ts — push services write to that address about this server — then x vapid create for the key pair; or set pwa.push: false and delete pwa.vapid";
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* One `pwa.vapid.pushHosts` entry: a bare DNS name with a dot — no scheme, port, path or wildcard
|
|
254
|
+
* (a listed name admits its subdomains), never `localhost` or an IP literal, which would hand the
|
|
255
|
+
* push sender back the hole the list closes. Checked at boot, so `@ultimat3/pwa`'s list holds only
|
|
256
|
+
* names that passed it.
|
|
257
|
+
*/
|
|
258
|
+
export function pushHostShape(entry: unknown): string | undefined {
|
|
259
|
+
if (typeof entry !== 'string') return 'is not a string';
|
|
260
|
+
if (
|
|
261
|
+
!/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$/i.test(
|
|
262
|
+
entry,
|
|
263
|
+
)
|
|
264
|
+
) {
|
|
265
|
+
return 'is not a host name like push.example.com (no scheme, port, path or wildcard)';
|
|
266
|
+
}
|
|
267
|
+
if (/\.localhost$/i.test(entry)) return 'names this machine';
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** `mailto:someone` or an absolute `https:` URL — RFC 8292's two forms, nothing else. */
|
|
272
|
+
const isVapidSubject = (value: unknown): boolean => {
|
|
273
|
+
if (typeof value !== 'string') return false;
|
|
274
|
+
if (/^mailto:[^\s@]+@[^\s@]+$/.test(value)) return true;
|
|
275
|
+
try {
|
|
276
|
+
return new URL(value).protocol === 'https:';
|
|
277
|
+
} catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The push rules, apart from `pwaIssues` because their remedy is another sentence. Push asks for
|
|
284
|
+
* nothing unless the app is installable, exactly like the rest of the block — `enabled: false`
|
|
285
|
+
* emits no worker for a push handler to live in.
|
|
286
|
+
*/
|
|
287
|
+
export function pwaPushIssues(pwa: PwaConfig, issues: string[]): boolean {
|
|
288
|
+
if (!pwa.enabled) return false;
|
|
289
|
+
const before = issues.length;
|
|
290
|
+
const vapid: unknown = pwa.vapid;
|
|
291
|
+
if (!pwa.push) {
|
|
292
|
+
if (vapid !== undefined) {
|
|
293
|
+
issues.push('pwa.vapid is set while pwa.push is false, so nothing reads it');
|
|
294
|
+
}
|
|
295
|
+
return issues.length > before;
|
|
296
|
+
}
|
|
297
|
+
// `push: true` with no `vapid` at all booted on 26.0.0, where the key wired nothing — so in 26.x
|
|
298
|
+
// it still boots, with push left unwired (`pushWired`) and the boot saying so. 27.0.0 refuses it.
|
|
299
|
+
if (vapid === undefined) return false;
|
|
300
|
+
const subject: unknown =
|
|
301
|
+
vapid !== null && typeof vapid === 'object' ? (vapid as { subject?: unknown }).subject : vapid;
|
|
302
|
+
if (!isVapidSubject(subject)) {
|
|
303
|
+
issues.push(
|
|
304
|
+
`pwa.vapid.subject is required when pwa.push is true and must be a mailto: address or an https: URL, and is ${describeValue(subject)}`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
const hosts: unknown =
|
|
308
|
+
vapid !== null && typeof vapid === 'object'
|
|
309
|
+
? (vapid as { pushHosts?: unknown }).pushHosts
|
|
310
|
+
: undefined;
|
|
311
|
+
if (hosts !== undefined) {
|
|
312
|
+
if (!Array.isArray(hosts)) {
|
|
313
|
+
issues.push(`pwa.vapid.pushHosts must be a list of host names, not ${describeValue(hosts)}`);
|
|
314
|
+
} else {
|
|
315
|
+
for (const host of hosts as readonly unknown[]) {
|
|
316
|
+
const problem = pushHostShape(host);
|
|
317
|
+
if (problem !== undefined) {
|
|
318
|
+
issues.push(`pwa.vapid.pushHosts contains ${describeValue(host)}, which ${problem}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return issues.length > before;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Whether this config wires Web Push: an installable app, `push: true`, and a `vapid` block. The
|
|
328
|
+
* ONE answer the boot, the worker, `.env.example` and the drift gate read — `push: true` without
|
|
329
|
+
* `vapid` is accepted in 26.x (it was on 26.0.0) and wires nothing.
|
|
330
|
+
*/
|
|
331
|
+
export const pushWired = (pwa: PwaConfig): boolean =>
|
|
332
|
+
pwa.enabled && pwa.push && pwa.vapid !== undefined;
|
|
333
|
+
|
|
220
334
|
/** A path the manifest names must be absolute: a relative one resolves against the manifest's URL. */
|
|
221
335
|
const absolute = (value: unknown): boolean => typeof value === 'string' && value.startsWith('/');
|
|
222
336
|
|
package/src/config.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { type Input, lastSaid, layered } from './config-merge';
|
|
|
20
20
|
import type { NavigationConfig, NavigationSectionInput } from './config-navigation';
|
|
21
21
|
import { mergeNavigation, navigationIssues } from './config-navigation';
|
|
22
22
|
import type { PwaConfig, PwaOfflineConfig } from './config-pwa';
|
|
23
|
-
import { PWA_FIX, pwaIssues } from './config-pwa';
|
|
23
|
+
import { PWA_FIX, PWA_PUSH_FIX, pwaIssues, pwaPushIssues } from './config-pwa';
|
|
24
24
|
import { removedKeyFix, removedKeyIssue, removedKeysIn } from './config-removed';
|
|
25
25
|
import {
|
|
26
26
|
booleanIssue,
|
|
@@ -318,6 +318,7 @@ function validate(config: AppConfig): void {
|
|
|
318
318
|
// What an install needs, asked at BOOT and not at emit — `config-pwa.ts` owns the rules and the
|
|
319
319
|
// remedy: `pwa.enabled` turning four other requirements on is a question about that block alone.
|
|
320
320
|
if (pwaIssues(config.pwa, issues)) pwaFix.push(PWA_FIX);
|
|
321
|
+
if (pwaPushIssues(config.pwa, issues)) pwaFix.push(PWA_PUSH_FIX);
|
|
321
322
|
siteIssues(config, issues);
|
|
322
323
|
navigationIssues(config, issues);
|
|
323
324
|
islandsIssues(config, issues);
|
package/src/index.ts
CHANGED
|
@@ -171,8 +171,15 @@ export type {
|
|
|
171
171
|
PwaScreenshot,
|
|
172
172
|
PwaShortcut,
|
|
173
173
|
PwaText,
|
|
174
|
+
PwaVapidConfig,
|
|
175
|
+
} from './config-pwa';
|
|
176
|
+
export {
|
|
177
|
+
isSameOriginPath,
|
|
178
|
+
PWA_COLOR_KEYS,
|
|
179
|
+
PWA_PUSH_FIX,
|
|
180
|
+
PWA_SCHEMES,
|
|
181
|
+
pushWired,
|
|
174
182
|
} from './config-pwa';
|
|
175
|
-
export { isSameOriginPath, PWA_COLOR_KEYS, PWA_SCHEMES } from './config-pwa';
|
|
176
183
|
export type {
|
|
177
184
|
SeoConfig,
|
|
178
185
|
SeoConfigInput,
|
package/src/registrar.ts
CHANGED
|
@@ -86,6 +86,8 @@ export const PRIMITIVE_FACTORIES = Object.freeze<readonly PrimitiveFactory[]>(
|
|
|
86
86
|
{ factory: 'webhook', pkg: '@ultimat3/jobs', kind: 'job' },
|
|
87
87
|
{ factory: 'mcpConfirmations', pkg: '@ultimat3/mcp', kind: 'action' },
|
|
88
88
|
{ factory: 'notifier', pkg: '@ultimat3/notify', kind: 'job' },
|
|
89
|
+
{ factory: 'pushSubscribe', pkg: '@ultimat3/pwa', kind: 'action' },
|
|
90
|
+
{ factory: 'pushUnsubscribe', pkg: '@ultimat3/pwa', kind: 'action' },
|
|
89
91
|
{ factory: 'scrape', pkg: '@ultimat3/scraping', kind: 'job' },
|
|
90
92
|
] satisfies readonly PrimitiveFactory[]
|
|
91
93
|
).map((entry) => Object.freeze(entry)),
|