@ultimat3/core 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/core",
3
- "version": "26.0.0",
3
+ "version": "26.1.0",
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.0.0"
40
+ "@ultimat3/schema": "26.1.0"
41
41
  }
42
42
  }
@@ -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)),