@ultimat3/pwa 25.2.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.
@@ -0,0 +1,87 @@
1
+ // Single responsibility: the one Web Push runtime a process holds — the subscription store, the
2
+ // imported VAPID signer, and the transport — installed by the boot and read per call by the
3
+ // subscribe actions and the sender.
4
+ //
5
+ // Read PER CALL, never captured at declaration: an app's `pushSubscribe()` is declared when its
6
+ // modules import, and the boot installs this before that (`@ultimat3/cli`'s `runtime-services.ts`)
7
+ // — but a test installs its own after, and a captured value would be the previous test's.
8
+
9
+ import type { Clock } from '@ultimat3/core';
10
+ import { systemClock } from '@ultimat3/core';
11
+ import { translatorFor } from '@ultimat3/i18n';
12
+ import { PwaPushUnconfiguredError } from './errors';
13
+ import type { Translate, VapidConfig } from './push';
14
+ import type { VapidSigner } from './push-send';
15
+ import type { PushSubscriptionStore } from './push-store';
16
+ import type { VapidKeyPair } from './vapid';
17
+ import { importVapidKeys } from './vapid';
18
+
19
+ export interface WebPushOptions {
20
+ readonly store: PushSubscriptionStore;
21
+ /** The pair `resolveVapidKeys` answered — the boot checked it is one. */
22
+ readonly keys: VapidKeyPair;
23
+ /** `pwa.vapid.subject`: `mailto:` or `https:`. */
24
+ readonly subject: string;
25
+ readonly fetch?: typeof fetch | undefined;
26
+ readonly clock?: Clock | undefined;
27
+ /** `pwa.vapid.pushHosts`: push services beyond the built-in list, at subscribe and at send. */
28
+ readonly pushHosts?: readonly string[] | undefined;
29
+ /**
30
+ * The catalog a subscriber's locale renders from. `@ultimat3/i18n`'s `translatorFor` — the app's
31
+ * registered catalogs — unless a test hands its own.
32
+ */
33
+ readonly translate?: ((locale: string) => Translate) | undefined;
34
+ }
35
+
36
+ export interface WebPushRuntime {
37
+ readonly store: PushSubscriptionStore;
38
+ readonly signer: VapidSigner;
39
+ readonly fetch: typeof fetch;
40
+ readonly clock: Clock;
41
+ readonly translate: (locale: string) => Translate;
42
+ readonly pushHosts: readonly string[];
43
+ }
44
+
45
+ let installed: WebPushRuntime | undefined;
46
+
47
+ /**
48
+ * Install the runtime and answer its release. Whole-object replacement, never a merge. The key is
49
+ * imported once here, not per send: a notifier fanning out to a thousand devices signs a thousand
50
+ * tokens with one `CryptoKey`.
51
+ */
52
+ export async function installWebPush(options: WebPushOptions): Promise<() => void> {
53
+ const keys = await importVapidKeys(options.keys);
54
+ const runtime: WebPushRuntime = {
55
+ store: options.store,
56
+ signer: { keys, publicKey: options.keys.publicKey, subject: options.subject },
57
+ fetch: options.fetch ?? fetch,
58
+ clock: options.clock ?? systemClock,
59
+ translate: options.translate ?? ((locale) => translatorFor(locale)),
60
+ pushHosts: options.pushHosts ?? [],
61
+ };
62
+ installed = runtime;
63
+ return () => {
64
+ if (installed === runtime) installed = undefined;
65
+ };
66
+ }
67
+
68
+ /** The installed runtime, or `X_PWA_PUSH_UNCONFIGURED` naming what tried to use it. */
69
+ export function webPushRuntime(operation: string): WebPushRuntime {
70
+ if (installed === undefined) throw new PwaPushUnconfiguredError({ operation });
71
+ return installed;
72
+ }
73
+
74
+ /**
75
+ * The public half a browser subscribes with and the subject, or `undefined` when no runtime is
76
+ * installed — what the web role writes into `<meta name="x-push-key">` and gates `sw.js`'s push
77
+ * handler on.
78
+ */
79
+ export const installedVapid = (): VapidConfig | undefined =>
80
+ installed === undefined
81
+ ? undefined
82
+ : { publicKey: installed.signer.publicKey, subject: installed.signer.subject };
83
+
84
+ /** Test seam. */
85
+ export function resetWebPush(): void {
86
+ installed = undefined;
87
+ }
@@ -0,0 +1,27 @@
1
+ // The DDL of Web Push's one framework table, and nothing else: a LEAF module, so the boot's
2
+ // `FRAMEWORK_SCHEMA` (`@ultimat3/cli`) applies it in every role through `@ultimat3/pwa/schema`
3
+ // without pulling the service-worker generator, the sender or `@ultimat3/action` into a worker's
4
+ // or a migrate pod's module graph — `@ultimat3/mcp/schema`'s shape. Imports nothing, by design.
5
+
6
+ /** The table. One name, read by this DDL and by `push-store-pg.ts`'s statements. */
7
+ export const PUSH_SUBSCRIPTIONS_TABLE = 'x_push_subscriptions';
8
+
9
+ /**
10
+ * Applied by the boot, never by an app migration; `if not exists` throughout, so every boot can.
11
+ * `endpoint` is the key (`push-store.ts` says why). `actor_id` is indexed because the one hot read
12
+ * is "every device of this person", once per notification sent.
13
+ */
14
+ export const SQL_PUSH_SUBSCRIPTIONS_TABLE = `
15
+ create table if not exists x_push_subscriptions (
16
+ endpoint text primary key,
17
+ actor_id text not null,
18
+ p256dh text not null,
19
+ auth text not null,
20
+ locale text not null,
21
+ time_zone text not null,
22
+ expiration_time bigint,
23
+ created_at timestamptz not null default now()
24
+ );
25
+
26
+ create index if not exists x_push_subscriptions_actor_idx on x_push_subscriptions (actor_id);
27
+ `;
@@ -0,0 +1,191 @@
1
+ // Single responsibility: ONE push message to ONE subscription — encrypt (RFC 8291), sign
2
+ // (RFC 8292), POST (RFC 8030), and read the answer into one of three outcomes: delivered, gone, or
3
+ // a coded refusal saying whether a retry can help.
4
+
5
+ import type { Clock } from '@ultimat3/core';
6
+ import {
7
+ classifyAddress,
8
+ isRetryableStatus,
9
+ renderThrowable,
10
+ retryAfterSecondsOf,
11
+ } from '@ultimat3/core';
12
+ import {
13
+ PwaPushFailedError,
14
+ PwaPushHostUnlistedError,
15
+ PwaPushRejectedError,
16
+ PwaPushSubscriptionInvalidError,
17
+ } from './errors';
18
+ import { encodeBase64Url } from './push-bytes';
19
+ import type { PushEncryptionKeys } from './push-encrypt';
20
+ import { encryptPushMessage } from './push-encrypt';
21
+ import { pushHostAllowed } from './push-hosts';
22
+ import { vapidAuthorization } from './vapid';
23
+
24
+ /** RFC 8030 §5.3. `high` wakes a sleeping phone; reserve it for what a person is waiting on. */
25
+ export const PUSH_URGENCIES = ['very-low', 'low', 'normal', 'high'] as const;
26
+ export type PushUrgency = (typeof PUSH_URGENCIES)[number];
27
+
28
+ /** A day. The push service drops an undelivered message after this — a stale alert is noise. */
29
+ export const DEFAULT_PUSH_TTL_SECONDS = 86_400;
30
+
31
+ /** How one message travels, as opposed to what it says. */
32
+ export interface PushDelivery {
33
+ /** Seconds the push service holds the message for an offline device. 0 = now or never. */
34
+ readonly ttlSeconds?: number | undefined;
35
+ readonly urgency?: PushUrgency | undefined;
36
+ /**
37
+ * Collapse key AT THE PUSH SERVICE: a newer message with the same topic replaces one still
38
+ * queued for an offline device. Any string — it is sent as the first 32 characters of its
39
+ * SHA-256 in base64url, because the header admits exactly 32 URL-safe characters.
40
+ */
41
+ readonly topic?: string | undefined;
42
+ }
43
+
44
+ /** The signing half, resolved once per process: `push-runtime.ts` holds it. */
45
+ export interface VapidSigner {
46
+ readonly keys: CryptoKeyPair;
47
+ readonly publicKey: string;
48
+ readonly subject: string;
49
+ }
50
+
51
+ export interface PushTarget {
52
+ readonly endpoint: string;
53
+ readonly keys: PushEncryptionKeys;
54
+ }
55
+
56
+ export interface PushSendInput {
57
+ readonly target: PushTarget;
58
+ /** The notification, serialized — `serializePushMessage`'s output, UTF-8 encoded. */
59
+ readonly plaintext: Uint8Array;
60
+ readonly vapid: VapidSigner;
61
+ readonly delivery?: PushDelivery | undefined;
62
+ readonly fetch?: typeof fetch | undefined;
63
+ readonly clock?: Clock | undefined;
64
+ readonly signal?: AbortSignal | undefined;
65
+ /** `pwa.vapid.pushHosts`: push services beyond the built-in list (`push-hosts.ts`). */
66
+ readonly pushHosts?: readonly string[] | undefined;
67
+ }
68
+
69
+ export type PushSendOutcome =
70
+ | { readonly kind: 'delivered'; readonly status: number }
71
+ /** 404/410: the subscription is dead. Delete it; never retry it. */
72
+ | { readonly kind: 'gone'; readonly status: 404 | 410 };
73
+
74
+ /** How long one push service may take to answer before the fan-out moves on (retryable). */
75
+ export const PUSH_SEND_TIMEOUT_MS = 30_000;
76
+
77
+ /**
78
+ * `https:` to a host on the public internet — every push service a browser hands out is one. The
79
+ * endpoint came from a REQUEST BODY, so it is where a signed-in caller points this server: `http:`
80
+ * would send in the clear, and `localhost` or an IP literal that is not public (loopback, private,
81
+ * link-local, the metadata address) would make the server POST to its own side of the network.
82
+ * No carve-out for tests: a stub push service is an injected `fetch` over an `https:` URL.
83
+ */
84
+ export function pushEndpointProblem(endpoint: string): string | undefined {
85
+ let url: URL;
86
+ try {
87
+ url = new URL(endpoint);
88
+ } catch {
89
+ return 'is not an absolute URL';
90
+ }
91
+ if (url.protocol !== 'https:') return 'is not an https URL';
92
+ const host = url.hostname.toLowerCase();
93
+ if (host === 'localhost' || host.endsWith('.localhost')) return 'names this machine';
94
+ const kind = classifyAddress(host);
95
+ if (kind !== undefined && kind !== 'public') return `is a ${kind} address`;
96
+ return undefined;
97
+ }
98
+
99
+ async function topicHeader(topic: string): Promise<string> {
100
+ const digest = new Uint8Array(
101
+ await crypto.subtle.digest('SHA-256', new TextEncoder().encode(topic)),
102
+ );
103
+ return encodeBase64Url(digest).slice(0, 32);
104
+ }
105
+
106
+ const ttlOf = (delivery: PushDelivery | undefined): number => {
107
+ const ttl = delivery?.ttlSeconds ?? DEFAULT_PUSH_TTL_SECONDS;
108
+ return Number.isFinite(ttl) && ttl >= 0 ? Math.floor(ttl) : DEFAULT_PUSH_TTL_SECONDS;
109
+ };
110
+
111
+ /**
112
+ * The screen an endpoint passes before it is stored and before every send: a well-formed public
113
+ * `https:` URL (`X_PWA_PUSH_SUBSCRIPTION_INVALID`) on a push service this app sends to
114
+ * (`X_PWA_PUSH_HOST_UNLISTED`). The list, not the name's shape, is what stops a hostname that
115
+ * resolves to a private address: shape alone is checked before DNS, and DNS answers the attacker.
116
+ */
117
+ export function assertPushEndpoint(endpoint: string, pushHosts: readonly string[] = []): void {
118
+ const problem = pushEndpointProblem(endpoint);
119
+ if (problem !== undefined) {
120
+ throw new PwaPushSubscriptionInvalidError({ field: 'endpoint', reason: problem });
121
+ }
122
+ const host = new URL(endpoint).hostname;
123
+ if (!pushHostAllowed(host, pushHosts)) throw new PwaPushHostUnlistedError({ host });
124
+ }
125
+
126
+ /** Delivered, gone, or a coded throw: `X_PWA_PUSH_FAILED` (retry) / `X_PWA_PUSH_REJECTED` (don't). */
127
+ export async function sendPushMessage(input: PushSendInput): Promise<PushSendOutcome> {
128
+ assertPushEndpoint(input.target.endpoint, input.pushHosts);
129
+ const origin = new URL(input.target.endpoint).origin;
130
+ const body = await encryptPushMessage(input.target.keys, input.plaintext);
131
+ const headers: Record<string, string> = {
132
+ authorization: await vapidAuthorization({
133
+ endpoint: input.target.endpoint,
134
+ subject: input.vapid.subject,
135
+ keys: input.vapid.keys,
136
+ publicKey: input.vapid.publicKey,
137
+ clock: input.clock,
138
+ }),
139
+ 'content-encoding': 'aes128gcm',
140
+ 'content-type': 'application/octet-stream',
141
+ ttl: String(ttlOf(input.delivery)),
142
+ urgency: input.delivery?.urgency ?? 'normal',
143
+ };
144
+ if (input.delivery?.topic !== undefined && input.delivery.topic !== '') {
145
+ headers['topic'] = await topicHeader(input.delivery.topic);
146
+ }
147
+ let response: Response;
148
+ try {
149
+ response = await (input.fetch ?? fetch)(input.target.endpoint, {
150
+ method: 'POST',
151
+ headers,
152
+ body,
153
+ // Never followed: a redirect would carry a token signed for one origin to another.
154
+ redirect: 'manual',
155
+ // A deadline: one service that accepts and never answers must not hold the rest of the
156
+ // fan-out — every later device of this person waits behind it.
157
+ signal:
158
+ input.signal === undefined
159
+ ? AbortSignal.timeout(PUSH_SEND_TIMEOUT_MS)
160
+ : AbortSignal.any([input.signal, AbortSignal.timeout(PUSH_SEND_TIMEOUT_MS)]),
161
+ });
162
+ } catch (error) {
163
+ // The CALLER cancelled: not the push service's fault, so not the retryable class — a retry
164
+ // would re-deliver to every device already served. Rethrown as is; the fan-out stops.
165
+ if (input.signal?.aborted === true) throw error;
166
+ throw new PwaPushFailedError({ origin, status: null, detail: renderThrowable(error) });
167
+ }
168
+ if (response.status >= 200 && response.status < 300) {
169
+ await response.body?.cancel();
170
+ return { kind: 'delivered', status: response.status };
171
+ }
172
+ if (response.status === 404 || response.status === 410) {
173
+ await response.body?.cancel();
174
+ return { kind: 'gone', status: response.status };
175
+ }
176
+ if (isRetryableStatus(response.status)) {
177
+ await response.body?.cancel();
178
+ throw new PwaPushFailedError({
179
+ origin,
180
+ status: response.status,
181
+ detail: `status ${response.status}`,
182
+ retryAfterSeconds: retryAfterSecondsOf(
183
+ response.headers.get('retry-after'),
184
+ response.headers.get('date'),
185
+ ),
186
+ });
187
+ }
188
+ // The service's own words, bounded: FCM and Mozilla both name the field they refused.
189
+ const text = (await response.text().catch(() => '')).slice(0, 200);
190
+ throw new PwaPushRejectedError({ origin, status: response.status, body: text });
191
+ }
@@ -0,0 +1,86 @@
1
+ // Single responsibility: push subscriptions in Postgres — `x_push_subscriptions`, a framework table
2
+ // applied by the boot (`@ultimat3/cli`'s `FRAMEWORK_SCHEMA`) the way `x_jobs` and `x_notify_inbox`
3
+ // are, never an `entity()` an app's `x db gen` would own. Statements are spelled out so the one in
4
+ // a log is the one to run.
5
+
6
+ import type { PgExecutor } from '@ultimat3/core';
7
+ import type { PushSubscriptionRecord } from './push';
8
+ import type { PushSubscriptionStore } from './push-store';
9
+
10
+ /** A re-subscribe moves the endpoint to this actor and its new keys; `created_at` stays first. */
11
+ const SQL_PUSH_SAVE = `
12
+ insert into x_push_subscriptions
13
+ (endpoint, actor_id, p256dh, auth, locale, time_zone, expiration_time, created_at)
14
+ values ($1, $2, $3, $4, $5, $6, $7, $8)
15
+ on conflict (endpoint) do update set
16
+ actor_id = excluded.actor_id, p256dh = excluded.p256dh, auth = excluded.auth,
17
+ locale = excluded.locale, time_zone = excluded.time_zone,
18
+ expiration_time = excluded.expiration_time
19
+ `;
20
+
21
+ /** `collate "C"`: the memory store orders by code point, and one order is the contract. */
22
+ const SQL_PUSH_LIST = `
23
+ select endpoint, actor_id, p256dh, auth, locale, time_zone, expiration_time, created_at
24
+ from x_push_subscriptions where actor_id = $1 order by endpoint collate "C"
25
+ `;
26
+
27
+ /** `$2` null deletes whoever holds it — the sender's 404/410; an unsubscribe names its owner. */
28
+ const SQL_PUSH_REMOVE = `
29
+ delete from x_push_subscriptions
30
+ where endpoint = $1 and ($2::text is null or actor_id = $2)
31
+ returning endpoint
32
+ `;
33
+
34
+ interface PushDbRow {
35
+ readonly endpoint: string;
36
+ readonly actor_id: string;
37
+ readonly p256dh: string;
38
+ readonly auth: string;
39
+ readonly locale: string;
40
+ readonly time_zone: string;
41
+ readonly expiration_time: number | string | null;
42
+ readonly created_at: Date | string;
43
+ }
44
+
45
+ const fromRow = (row: PushDbRow): PushSubscriptionRecord => ({
46
+ endpoint: row.endpoint,
47
+ keys: { p256dh: row.p256dh, auth: row.auth },
48
+ locale: row.locale,
49
+ timeZone: row.time_zone,
50
+ actorId: row.actor_id,
51
+ // `bigint` arrives as a string from some drivers; it is epoch milliseconds either way.
52
+ expirationTime: row.expiration_time === null ? null : Number(row.expiration_time),
53
+ createdAt: (row.created_at instanceof Date ? row.created_at : new Date(row.created_at)).getTime(),
54
+ });
55
+
56
+ export interface PostgresPushSubscriptionStoreOptions {
57
+ /** `@ultimat3/db`'s `dbExecutor()` — `Bun.sql` does not satisfy it. */
58
+ readonly executor: PgExecutor;
59
+ }
60
+
61
+ export function postgresPushSubscriptionStore(
62
+ options: PostgresPushSubscriptionStoreOptions,
63
+ ): PushSubscriptionStore {
64
+ const { executor } = options;
65
+ return {
66
+ async save(record) {
67
+ await executor.query(SQL_PUSH_SAVE, [
68
+ record.endpoint,
69
+ record.actorId,
70
+ record.keys.p256dh,
71
+ record.keys.auth,
72
+ record.locale,
73
+ record.timeZone,
74
+ record.expirationTime,
75
+ new Date(record.createdAt),
76
+ ]);
77
+ },
78
+ async listFor(actorId) {
79
+ return (await executor.query<PushDbRow>(SQL_PUSH_LIST, [actorId])).map(fromRow);
80
+ },
81
+ async remove(endpoint, actorId) {
82
+ const rows = await executor.query(SQL_PUSH_REMOVE, [endpoint, actorId ?? null]);
83
+ return rows.length > 0;
84
+ },
85
+ };
86
+ }
@@ -0,0 +1,54 @@
1
+ // Single responsibility: where push subscriptions live — the seam, and the in-memory store a test
2
+ // or a single process uses. The Postgres store is `push-store-pg.ts`.
3
+ //
4
+ // A subscription is keyed by its ENDPOINT, never by (actor, endpoint): one browser profile holds
5
+ // one subscription, and when a second person signs in on that browser and subscribes, the
6
+ // subscription is theirs now. Keyed by the pair, the first person would go on receiving the second
7
+ // person's notifications on a device they no longer use.
8
+
9
+ import type { PushSubscriptionRecord } from './push';
10
+
11
+ export interface PushSubscriptionStore {
12
+ /** Insert, or hand an existing endpoint to this record's actor with its new keys and locale. */
13
+ save(record: PushSubscriptionRecord): Promise<void>;
14
+ /** Every subscription of one actor, ordered by endpoint (code point) — one order in both stores. */
15
+ listFor(actorId: string): Promise<readonly PushSubscriptionRecord[]>;
16
+ /**
17
+ * Delete one endpoint. With `actorId`, only when it is that actor's — an unsubscribe names its
18
+ * owner, so an endpoint somebody else holds is simply absent. Without, unconditionally — the
19
+ * sender's answer to a 404/410, which says the endpoint is dead whoever held it.
20
+ */
21
+ remove(endpoint: string, actorId?: string): Promise<boolean>;
22
+ }
23
+
24
+ const byEndpoint = (a: PushSubscriptionRecord, b: PushSubscriptionRecord): number =>
25
+ a.endpoint < b.endpoint ? -1 : a.endpoint > b.endpoint ? 1 : 0;
26
+
27
+ /** One process's subscriptions. Lost on restart, which is the right fate for a test's. */
28
+ export function memoryPushSubscriptionStore(): PushSubscriptionStore {
29
+ const rows = new Map<string, PushSubscriptionRecord>();
30
+ return {
31
+ save(record) {
32
+ const existing = rows.get(record.endpoint);
33
+ // The first `createdAt` survives a re-subscribe, as `on conflict do update` leaves it.
34
+ rows.set(
35
+ record.endpoint,
36
+ existing === undefined ? record : { ...record, createdAt: existing.createdAt },
37
+ );
38
+ return Promise.resolve();
39
+ },
40
+ listFor(actorId) {
41
+ return Promise.resolve(
42
+ [...rows.values()].filter((row) => row.actorId === actorId).sort(byEndpoint),
43
+ );
44
+ },
45
+ remove(endpoint, actorId) {
46
+ const row = rows.get(endpoint);
47
+ if (row === undefined || (actorId !== undefined && row.actorId !== actorId)) {
48
+ return Promise.resolve(false);
49
+ }
50
+ rows.delete(endpoint);
51
+ return Promise.resolve(true);
52
+ },
53
+ };
54
+ }
package/src/skew.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The PAGE's half of version skew: the message the generated worker posts on activation, and the
3
+ * comparison a page makes against its own build meta. Split from `version-skew.ts` (ids, retention)
4
+ * so the browser entry (`@ultimat3/pwa/client`) carries it with no error table and no server code.
5
+ */
6
+
7
+ import type { APP_UPDATE_MESSAGE } from '@ultimat3/core/page';
8
+
9
+ export type SkewState = 'current' | 'stale' | 'unknown';
10
+
11
+ /** `unknown` means "no id sent" — a first load, a crawler, or a cache-busted client. */
12
+ export function detectSkew(
13
+ clientBuildId: string | null | undefined,
14
+ serverBuildId: string,
15
+ ): SkewState {
16
+ if (clientBuildId === null || clientBuildId === undefined || clientBuildId.trim() === '') {
17
+ return 'unknown';
18
+ }
19
+ return clientBuildId === serverBuildId ? 'current' : 'stale';
20
+ }
21
+
22
+ /**
23
+ * The client-side contract, and the whole of it: what the generated worker posts to every page it
24
+ * controls on activation. The page compares `to` against its own `CLIENT_BUILD_META` meta (core's)
25
+ * — `detectSkew` is that comparison — and renders its own "refresh to update" affordance.
26
+ *
27
+ * It declared `from`, `forced` and `deadlineAt` too, for a forced reload after a grace period that
28
+ * NOTHING performed: `updateSignal`/`updatePolicy` computed the three and had no runtime caller,
29
+ * and `x deploy --critical`, the flag that was to have set the reason, was removed in 4.0.0 for
30
+ * being read by nobody. The two runtimes that hold both build ids cannot call into this package
31
+ * anyway — `http`'s `ctx.clientBuildId` (tier 2) and `sync`'s `update-available` frame (tier 3)
32
+ * are both BELOW `pwa`, and imports only go down. `version-skew.test.ts` holds this interface to
33
+ * the literal the worker emits, so the two can no longer differ.
34
+ */
35
+ export interface AppUpdateAvailable {
36
+ readonly type: typeof APP_UPDATE_MESSAGE;
37
+ /** The build the worker that posted this was generated for. */
38
+ readonly to: string;
39
+ }
@@ -0,0 +1,72 @@
1
+ // Single responsibility: which VAPID pair this process signs with — read from the environment, the
2
+ // published development pair in development/test, and a refusal everywhere else.
3
+ //
4
+ // The cursor secret's rule, applied to a key pair (`@ultimat3/core`'s `devSecretsRefused`): a local
5
+ // process works with no setup at all, and a deployed one that would sign with keys printed in this
6
+ // file is refused at boot. Both halves come from the environment, never one from `app.config.ts`:
7
+ // a public key in committed config and a private key per deploy are two places that can disagree,
8
+ // and the disagreement is a 403 on every send.
9
+
10
+ import { devSecretsRefused, timingSafeEqual } from '@ultimat3/core';
11
+ import { PwaVapidKeyMissingError } from './errors';
12
+ import type { VapidKeyPair } from './vapid';
13
+ import { assertVapidPair, VAPID_PRIVATE_KEY_ENV, VAPID_PUBLIC_KEY_ENV } from './vapid';
14
+
15
+ /**
16
+ * PUBLISHED, and so worthless outside one machine: the pair a development or test process signs
17
+ * with when neither variable is set. A subscription made against it lives in that machine's
18
+ * database. Refused by the boot anywhere `devSecretsRefused` holds, exactly as the dev cursor key is.
19
+ */
20
+ export const DEV_VAPID_KEYS: VapidKeyPair = Object.freeze({
21
+ publicKey:
22
+ 'BBwNsQrlkkkQ3pNRIpJVgq1VQk-Q6hG49IqxMDe-4X9Ks_5mUZcXb49zGt_vmJ1vNxy43CtF0CgANw8TXj6pA-c',
23
+ privateKey: 'qkWoagc9adrZnnpYJpI3kM9DjA8B_5qJkJj7Yi9izLM',
24
+ });
25
+
26
+ type EnvTable = Readonly<Record<string, string | undefined>>;
27
+
28
+ const read = (env: EnvTable, key: string): string | undefined => {
29
+ const value = env[key]?.trim();
30
+ return value === undefined || value === '' ? undefined : value;
31
+ };
32
+
33
+ /**
34
+ * True while `env` would leave a process signing with `DEV_VAPID_KEYS`: neither variable set, or
35
+ * the published private key pasted back in. `x env check` and `x doctor` ask it of a deploy's table.
36
+ */
37
+ export function usesDevVapidKeys(env: EnvTable): boolean {
38
+ const privateKey = read(env, VAPID_PRIVATE_KEY_ENV);
39
+ return privateKey === undefined || timingSafeEqual(privateKey, DEV_VAPID_KEYS.privateKey);
40
+ }
41
+
42
+ export interface ResolvedVapidKeys {
43
+ readonly keys: VapidKeyPair;
44
+ /** `'environment'` or `'development'` — logged once at boot, so a reader knows which pair. */
45
+ readonly source: 'environment' | 'development';
46
+ }
47
+
48
+ /**
49
+ * The pair, checked: both halves present (or neither, locally), each well-formed, and the private
50
+ * half signing for the public one. Half a pair is refused in EVERY environment — a developer who
51
+ * set one variable meant to set both, and silently signing with the dev pair would subscribe their
52
+ * browser to a key their production deploy never holds.
53
+ */
54
+ export async function resolveVapidKeys(env: EnvTable): Promise<ResolvedVapidKeys> {
55
+ const publicKey = read(env, VAPID_PUBLIC_KEY_ENV);
56
+ const privateKey = read(env, VAPID_PRIVATE_KEY_ENV);
57
+ if (publicKey !== undefined && privateKey !== undefined) {
58
+ const keys = { publicKey, privateKey };
59
+ if (devSecretsRefused({ env }) && timingSafeEqual(privateKey, DEV_VAPID_KEYS.privateKey)) {
60
+ throw new PwaVapidKeyMissingError({ missing: [VAPID_PRIVATE_KEY_ENV], deployed: true });
61
+ }
62
+ await assertVapidPair(keys);
63
+ return { keys, source: 'environment' };
64
+ }
65
+ const missing = [
66
+ ...(publicKey === undefined ? [VAPID_PUBLIC_KEY_ENV] : []),
67
+ ...(privateKey === undefined ? [VAPID_PRIVATE_KEY_ENV] : []),
68
+ ];
69
+ const deployed = devSecretsRefused({ env });
70
+ if (missing.length === 1 || deployed) throw new PwaVapidKeyMissingError({ missing, deployed });
71
+ return { keys: DEV_VAPID_KEYS, source: 'development' };
72
+ }