@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.
- package/CLAUDE.md +16 -5
- package/README.md +96 -3
- package/package.json +9 -3
- package/src/client.ts +37 -0
- package/src/errors.ts +156 -1
- package/src/index.ts +64 -20
- package/src/install.ts +2 -1
- package/src/push-actions.ts +104 -0
- package/src/push-bytes.ts +39 -0
- package/src/push-client.ts +182 -0
- package/src/push-decrypt.ts +92 -0
- package/src/push-encrypt.ts +161 -0
- package/src/push-hosts.ts +34 -0
- package/src/push-runtime.ts +87 -0
- package/src/push-schema.ts +27 -0
- package/src/push-send.ts +191 -0
- package/src/push-store-pg.ts +86 -0
- package/src/push-store.ts +54 -0
- package/src/skew.ts +39 -0
- package/src/vapid-keys.ts +72 -0
- package/src/vapid.ts +167 -0
- package/src/version-skew.ts +2 -32
- package/src/web-push.ts +124 -0
package/src/vapid.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Single responsibility: the application server's identity to a push service — RFC 8292 VAPID.
|
|
2
|
+
// A P-256 key pair, and the ES256 JWT that proves this server holds the private half of the key a
|
|
3
|
+
// subscription was made with. WebCrypto only: no `jose`, no `web-push` (docs/idea/18-build-vs-wrap.md
|
|
4
|
+
// — the whole of RFC 8292 is one signature over two JSON objects).
|
|
5
|
+
//
|
|
6
|
+
// Keys travel as the format every push library prints: the public key as the 65-byte uncompressed
|
|
7
|
+
// point and the private key as the 32-byte scalar, both unpadded base64url. A key pair minted by
|
|
8
|
+
// `web-push generate-vapid-keys` therefore works here unchanged.
|
|
9
|
+
|
|
10
|
+
import type { Clock } from '@ultimat3/core';
|
|
11
|
+
import { systemClock } from '@ultimat3/core';
|
|
12
|
+
import { PwaVapidKeyInvalidError } from './errors';
|
|
13
|
+
import { encodeBase64Url, tryDecodeBase64Url } from './push-bytes';
|
|
14
|
+
|
|
15
|
+
/** The pair as configuration holds it: two base64url strings. */
|
|
16
|
+
export interface VapidKeyPair {
|
|
17
|
+
/** 65-byte uncompressed P-256 point, base64url — what a browser's `applicationServerKey` takes. */
|
|
18
|
+
readonly publicKey: string;
|
|
19
|
+
/** 32-byte P-256 scalar, base64url. A secret: `ULTIMATE_VAPID_PRIVATE_KEY`. */
|
|
20
|
+
readonly privateKey: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The env var each half is read from — the names `x vapid create` seals and the boot reads. */
|
|
24
|
+
export const VAPID_PUBLIC_KEY_ENV = 'ULTIMATE_VAPID_PUBLIC_KEY';
|
|
25
|
+
export const VAPID_PRIVATE_KEY_ENV = 'ULTIMATE_VAPID_PRIVATE_KEY';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* RFC 8292 §2: `exp` no more than 24 hours ahead. Twelve, because a push service compares it with
|
|
29
|
+
* its own clock, and a server whose clock runs ahead by minutes must not produce a token the
|
|
30
|
+
* service reads as 24 hours and one minute.
|
|
31
|
+
*/
|
|
32
|
+
export const VAPID_TOKEN_TTL_SECONDS = 12 * 60 * 60;
|
|
33
|
+
|
|
34
|
+
const P256 = { name: 'ECDSA', namedCurve: 'P-256' } as const;
|
|
35
|
+
|
|
36
|
+
/** The 65-byte point, or the refusal naming which key and why. */
|
|
37
|
+
export function vapidPublicKeyBytes(
|
|
38
|
+
publicKey: string,
|
|
39
|
+
key = VAPID_PUBLIC_KEY_ENV,
|
|
40
|
+
): Uint8Array<ArrayBuffer> {
|
|
41
|
+
const bytes = tryDecodeBase64Url(publicKey);
|
|
42
|
+
if (bytes === undefined || bytes.byteLength !== 65 || bytes[0] !== 4) {
|
|
43
|
+
throw new PwaVapidKeyInvalidError({
|
|
44
|
+
key,
|
|
45
|
+
reason: 'is not a 65-byte uncompressed P-256 point in base64url (87 characters, starting B)',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return bytes;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Import the pair for ECDSA (signing a VAPID token) or ECDH (RFC 8291's key agreement — the same
|
|
53
|
+
* import, so a test can hand the RFC's application-server pair to the encryptor).
|
|
54
|
+
*
|
|
55
|
+
* JWK because WebCrypto has no raw-scalar import: `d` is the private key, `x`/`y` the public point.
|
|
56
|
+
* WebCrypto does NOT check that `d` belongs to `x`/`y`, so a mismatched pair imports fine and signs
|
|
57
|
+
* tokens no push service will verify — `assertVapidPair` is the check, run once at boot.
|
|
58
|
+
*/
|
|
59
|
+
export async function importVapidKeys(
|
|
60
|
+
pair: VapidKeyPair,
|
|
61
|
+
algorithm: 'ECDSA' | 'ECDH' = 'ECDSA',
|
|
62
|
+
): Promise<CryptoKeyPair> {
|
|
63
|
+
const point = vapidPublicKeyBytes(pair.publicKey);
|
|
64
|
+
const scalar = tryDecodeBase64Url(pair.privateKey);
|
|
65
|
+
if (scalar === undefined || scalar.byteLength !== 32) {
|
|
66
|
+
throw new PwaVapidKeyInvalidError({
|
|
67
|
+
key: VAPID_PRIVATE_KEY_ENV,
|
|
68
|
+
reason: 'is not a 32-byte P-256 scalar in base64url (43 characters)',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const jwk: JsonWebKey = {
|
|
72
|
+
kty: 'EC',
|
|
73
|
+
crv: 'P-256',
|
|
74
|
+
x: encodeBase64Url(point.slice(1, 33)),
|
|
75
|
+
y: encodeBase64Url(point.slice(33, 65)),
|
|
76
|
+
ext: true,
|
|
77
|
+
};
|
|
78
|
+
const params = { name: algorithm, namedCurve: 'P-256' } as const;
|
|
79
|
+
const usages: KeyUsage[] = algorithm === 'ECDSA' ? ['sign'] : ['deriveBits'];
|
|
80
|
+
try {
|
|
81
|
+
const privateKey = await crypto.subtle.importKey(
|
|
82
|
+
'jwk',
|
|
83
|
+
{ ...jwk, d: encodeBase64Url(scalar) },
|
|
84
|
+
params,
|
|
85
|
+
false,
|
|
86
|
+
usages,
|
|
87
|
+
);
|
|
88
|
+
const publicKey = await crypto.subtle.importKey(
|
|
89
|
+
'jwk',
|
|
90
|
+
jwk,
|
|
91
|
+
params,
|
|
92
|
+
true,
|
|
93
|
+
algorithm === 'ECDSA' ? ['verify'] : [],
|
|
94
|
+
);
|
|
95
|
+
return { privateKey, publicKey };
|
|
96
|
+
} catch {
|
|
97
|
+
throw new PwaVapidKeyInvalidError({
|
|
98
|
+
key: VAPID_PUBLIC_KEY_ENV,
|
|
99
|
+
reason: 'is not a point on the P-256 curve',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Sign once with the private half and verify with the public half. The only way to learn that two
|
|
106
|
+
* strings someone pasted into two variables are one key pair, and a wrong pair is otherwise a 403
|
|
107
|
+
* from every push service on every send, discovered by a user who never got a notification.
|
|
108
|
+
*/
|
|
109
|
+
export async function assertVapidPair(pair: VapidKeyPair): Promise<void> {
|
|
110
|
+
const keys = await importVapidKeys(pair);
|
|
111
|
+
const probe = new TextEncoder().encode('ultimate vapid pair check');
|
|
112
|
+
const signature = await crypto.subtle.sign({ ...P256, hash: 'SHA-256' }, keys.privateKey, probe);
|
|
113
|
+
const ok = await crypto.subtle.verify(
|
|
114
|
+
{ ...P256, hash: 'SHA-256' },
|
|
115
|
+
keys.publicKey,
|
|
116
|
+
signature,
|
|
117
|
+
probe,
|
|
118
|
+
);
|
|
119
|
+
if (!ok) {
|
|
120
|
+
throw new PwaVapidKeyInvalidError({
|
|
121
|
+
key: VAPID_PRIVATE_KEY_ENV,
|
|
122
|
+
reason: `does not sign for ${VAPID_PUBLIC_KEY_ENV}: the two halves are from different pairs`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A fresh pair, for `x vapid create`. The one place in this package that rolls key material. */
|
|
128
|
+
export async function generateVapidKeys(): Promise<VapidKeyPair> {
|
|
129
|
+
const pair = await crypto.subtle.generateKey(P256, true, ['sign', 'verify']);
|
|
130
|
+
const jwk = await crypto.subtle.exportKey('jwk', pair.privateKey);
|
|
131
|
+
const raw = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey));
|
|
132
|
+
return { publicKey: encodeBase64Url(raw), privateKey: jwk.d ?? '' };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface VapidTokenInput {
|
|
136
|
+
/** The subscription's endpoint; the token's audience is its ORIGIN, never the full URL. */
|
|
137
|
+
readonly endpoint: string;
|
|
138
|
+
/** `mailto:` or `https:` — who the push service writes to about this server. */
|
|
139
|
+
readonly subject: string;
|
|
140
|
+
readonly keys: CryptoKeyPair;
|
|
141
|
+
readonly publicKey: string;
|
|
142
|
+
readonly clock?: Clock | undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The `Authorization` header value: `vapid t=<jwt>, k=<public key>` (RFC 8292 §3). The signature is
|
|
147
|
+
* WebCrypto's raw `r || s`, which is exactly JWS ES256's encoding — no DER to unwrap.
|
|
148
|
+
*/
|
|
149
|
+
export async function vapidAuthorization(input: VapidTokenInput): Promise<string> {
|
|
150
|
+
const clock = input.clock ?? systemClock;
|
|
151
|
+
const exp = Math.floor(clock.now().getTime() / 1000) + VAPID_TOKEN_TTL_SECONDS;
|
|
152
|
+
const text = new TextEncoder();
|
|
153
|
+
const segment = (value: object): string => encodeBase64Url(text.encode(JSON.stringify(value)));
|
|
154
|
+
const unsigned = `${segment({ typ: 'JWT', alg: 'ES256' })}.${segment({
|
|
155
|
+
aud: new URL(input.endpoint).origin,
|
|
156
|
+
exp,
|
|
157
|
+
sub: input.subject,
|
|
158
|
+
})}`;
|
|
159
|
+
const signature = new Uint8Array(
|
|
160
|
+
await crypto.subtle.sign(
|
|
161
|
+
{ ...P256, hash: 'SHA-256' },
|
|
162
|
+
input.keys.privateKey,
|
|
163
|
+
text.encode(unsigned),
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
return `vapid t=${unsigned}.${encodeBase64Url(signature)}, k=${input.publicKey}`;
|
|
167
|
+
}
|
package/src/version-skew.ts
CHANGED
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
* The app decides what to do with that message. This package never navigates a client.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type { APP_UPDATE_MESSAGE } from '@ultimat3/core';
|
|
14
13
|
import { finiteCount } from '@ultimat3/core';
|
|
15
14
|
import { BuildIdMissingError } from './errors';
|
|
16
15
|
|
|
@@ -111,34 +110,5 @@ export function retentionPlan(deploys: readonly Deploy[], keep = 3): RetentionPl
|
|
|
111
110
|
};
|
|
112
111
|
}
|
|
113
112
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
/** `unknown` means "no id sent" — a first load, a crawler, or a cache-busted client. */
|
|
117
|
-
export function detectSkew(
|
|
118
|
-
clientBuildId: string | null | undefined,
|
|
119
|
-
serverBuildId: string,
|
|
120
|
-
): SkewState {
|
|
121
|
-
if (clientBuildId === null || clientBuildId === undefined || clientBuildId.trim() === '') {
|
|
122
|
-
return 'unknown';
|
|
123
|
-
}
|
|
124
|
-
return clientBuildId === serverBuildId ? 'current' : 'stale';
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* The client-side contract, and the whole of it: what the generated worker posts to every page it
|
|
129
|
-
* controls on activation. The page compares `to` against its own `CLIENT_BUILD_META` meta (core's)
|
|
130
|
-
* — `detectSkew` is that comparison — and renders its own "refresh to update" affordance.
|
|
131
|
-
*
|
|
132
|
-
* It declared `from`, `forced` and `deadlineAt` too, for a forced reload after a grace period that
|
|
133
|
-
* NOTHING performed: `updateSignal`/`updatePolicy` computed the three and had no runtime caller,
|
|
134
|
-
* and `x deploy --critical`, the flag that was to have set the reason, was removed in 4.0.0 for
|
|
135
|
-
* being read by nobody. The two runtimes that hold both build ids cannot call into this package
|
|
136
|
-
* anyway — `http`'s `ctx.clientBuildId` (tier 2) and `sync`'s `update-available` frame (tier 3)
|
|
137
|
-
* are both BELOW `pwa`, and imports only go down. `version-skew.test.ts` holds this interface to
|
|
138
|
-
* the literal the worker emits, so the two can no longer differ.
|
|
139
|
-
*/
|
|
140
|
-
export interface AppUpdateAvailable {
|
|
141
|
-
readonly type: typeof APP_UPDATE_MESSAGE;
|
|
142
|
-
/** The build the worker that posted this was generated for. */
|
|
143
|
-
readonly to: string;
|
|
144
|
-
}
|
|
113
|
+
// `SkewState`, `detectSkew` and `AppUpdateAvailable` — the page's half of skew — live in `skew.ts`,
|
|
114
|
+
// which the browser entry (`client.ts`) re-exports without this file's error table.
|
package/src/web-push.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Single responsibility: one notification to EVERY device of one person. Renders the payload's
|
|
2
|
+
// catalog keys in each subscription's own locale, sends each one, deletes the dead, and reports
|
|
3
|
+
// the rest — throwing only when a retry could change the answer.
|
|
4
|
+
//
|
|
5
|
+
// `webPush()` is the shape `@ultimat3/notify`'s `pushChannel({ pusher })` takes, declared there
|
|
6
|
+
// structurally (notify cannot import this package: same tier), so `notify(user, …)` reaches every
|
|
7
|
+
// subscription that user holds with no glue in the app.
|
|
8
|
+
|
|
9
|
+
import type { UltimateError } from '@ultimat3/core';
|
|
10
|
+
import { isUltimateError, logger } from '@ultimat3/core';
|
|
11
|
+
import type { PushPayload } from './push';
|
|
12
|
+
import { renderPushPayload, serializePushMessage, subscriptionState } from './push';
|
|
13
|
+
import { webPushRuntime } from './push-runtime';
|
|
14
|
+
import type { PushDelivery } from './push-send';
|
|
15
|
+
import { sendPushMessage } from './push-send';
|
|
16
|
+
|
|
17
|
+
/** What a notification says (catalog keys, never text) and how it travels. */
|
|
18
|
+
export interface PushNotification extends PushPayload, PushDelivery {}
|
|
19
|
+
|
|
20
|
+
export interface PushReport {
|
|
21
|
+
readonly delivered: number;
|
|
22
|
+
/** 404/410 or expired — deleted from the store. */
|
|
23
|
+
readonly removed: number;
|
|
24
|
+
/** Refused for good (`X_PWA_PUSH_REJECTED`, a body too large, a malformed key) — logged. */
|
|
25
|
+
readonly refused: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Every subscription of `actorId`, in endpoint order. A refusal that a retry cannot cure is logged
|
|
30
|
+
* with its code and fix and counted; a failure a retry CAN cure (`X_PWA_PUSH_FAILED`) is rethrown
|
|
31
|
+
* AFTER every other device was tried, so one flaky push service does not hold back the rest — and
|
|
32
|
+
* on the retry the devices that did receive it get it again. A notification with a `tag` replaces
|
|
33
|
+
* itself on those devices; one without shows twice, which is why a notifier should always set one.
|
|
34
|
+
*/
|
|
35
|
+
export async function pushToActor(
|
|
36
|
+
actorId: string,
|
|
37
|
+
notification: PushNotification,
|
|
38
|
+
signal?: AbortSignal,
|
|
39
|
+
): Promise<PushReport> {
|
|
40
|
+
const runtime = webPushRuntime('pushToActor');
|
|
41
|
+
const subscriptions = await runtime.store.listFor(actorId);
|
|
42
|
+
let delivered = 0;
|
|
43
|
+
let removed = 0;
|
|
44
|
+
let refused = 0;
|
|
45
|
+
let retry: unknown;
|
|
46
|
+
for (const subscription of subscriptions) {
|
|
47
|
+
if (subscriptionState(subscription, null, runtime.clock) === 'expired') {
|
|
48
|
+
await runtime.store.remove(subscription.endpoint);
|
|
49
|
+
removed += 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const rendered = renderPushPayload(
|
|
53
|
+
notification,
|
|
54
|
+
subscription.locale,
|
|
55
|
+
runtime.translate(subscription.locale),
|
|
56
|
+
);
|
|
57
|
+
for (const warning of rendered.warnings) logger.warn('pwa.push.render', { warning });
|
|
58
|
+
try {
|
|
59
|
+
const outcome = await sendPushMessage({
|
|
60
|
+
target: subscription,
|
|
61
|
+
plaintext: new TextEncoder().encode(serializePushMessage(rendered)),
|
|
62
|
+
vapid: runtime.signer,
|
|
63
|
+
delivery: notification,
|
|
64
|
+
fetch: runtime.fetch,
|
|
65
|
+
clock: runtime.clock,
|
|
66
|
+
signal,
|
|
67
|
+
pushHosts: runtime.pushHosts,
|
|
68
|
+
});
|
|
69
|
+
if (outcome.kind === 'gone') {
|
|
70
|
+
await runtime.store.remove(subscription.endpoint);
|
|
71
|
+
removed += 1;
|
|
72
|
+
} else {
|
|
73
|
+
delivered += 1;
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (isUltimateError(error) && error.code === 'X_PWA_PUSH_FAILED') {
|
|
77
|
+
retry ??= error;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (!isUltimateError(error)) throw error;
|
|
81
|
+
refused += 1;
|
|
82
|
+
// A stored subscription no send will ever reach is deleted, so the next notification does
|
|
83
|
+
// not pay for it again: a malformed key, or a 401/403 — made with a VAPID key this server no
|
|
84
|
+
// longer signs with. That browser re-subscribes under the new key the next time it asks.
|
|
85
|
+
if (
|
|
86
|
+
error.code === 'X_PWA_PUSH_SUBSCRIPTION_INVALID' ||
|
|
87
|
+
error.code === 'X_PWA_PUSH_HOST_UNLISTED' ||
|
|
88
|
+
unreachableUnderThisKey(error)
|
|
89
|
+
) {
|
|
90
|
+
await runtime.store.remove(subscription.endpoint);
|
|
91
|
+
}
|
|
92
|
+
logger.error('pwa.push.refused', { code: error.code, cause: error.cause, fix: error.fix });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (retry !== undefined) throw retry;
|
|
96
|
+
return { delivered, removed, refused };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A push service's 401/403: the subscription belongs to another VAPID key, for good. */
|
|
100
|
+
const unreachableUnderThisKey = (error: UltimateError): boolean => {
|
|
101
|
+
const status = (error.meta as { readonly status?: unknown } | undefined)?.status;
|
|
102
|
+
return error.code === 'X_PWA_PUSH_REJECTED' && (status === 401 || status === 403);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** The structural shape `@ultimat3/notify`'s `Pusher` declares. */
|
|
106
|
+
export interface WebPusher {
|
|
107
|
+
send(push: {
|
|
108
|
+
readonly to: string;
|
|
109
|
+
readonly message: PushNotification;
|
|
110
|
+
readonly signal: AbortSignal;
|
|
111
|
+
}): Promise<void>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The installed runtime as a notify pusher: `pushChannel({ pusher: webPush(), message })`. It
|
|
116
|
+
* reads the runtime per send, so it can be declared at module scope before the boot installs one.
|
|
117
|
+
*/
|
|
118
|
+
export function webPush(): WebPusher {
|
|
119
|
+
return {
|
|
120
|
+
async send({ to, message, signal }) {
|
|
121
|
+
await pushToActor(to, message, signal);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|