Skip to content
103 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import { b64url, decrypt, encrypt, fromB64url, generateVapidKeys, sendPush, vapidAuthorization, vapidJwt } from "./webpush.ts";
5
6// RFC 8291, Appendix A: the worked example, every input and the body.
7const RFC = {
8 plaintext: "When I grow up, I want to be a watermelon",
9 asPrivate: "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw",
10 asPublic: "BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8",
11 uaPrivate: "q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94",
12 uaPublic: "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4",
13 auth: "BTBZMqHH6r4Tts7J_aSIgg",
14 salt: "DGv6ra1nlYgDCS1FRnbzlw",
15 body:
16 "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN",
17};
18
19test("encryption reproduces RFC 8291's example byte for byte", async () => {
20 const body = await encrypt(new TextEncoder().encode(RFC.plaintext), { p256dh: RFC.uaPublic, auth: RFC.auth }, {
21 asPrivate: RFC.asPrivate,
22 asPublic: RFC.asPublic,
23 salt: fromB64url(RFC.salt),
24 });
25 assert.equal(b64url(body), RFC.body);
26});
27
28test("the example's body decrypts with the subscriber's key", async () => {
29 const plain = await decrypt(fromB64url(RFC.body), { privateKey: RFC.uaPrivate, publicKey: RFC.uaPublic, auth: RFC.auth });
30 assert.equal(new TextDecoder().decode(plain), RFC.plaintext);
31});
32
33test("a fresh key and salt each time, and it still round-trips", async () => {
34 const message = new TextEncoder().encode(JSON.stringify({ title: "Ana", body: "ship it? ✨" }));
35 const a = await encrypt(message, { p256dh: RFC.uaPublic, auth: RFC.auth });
36 const b = await encrypt(message, { p256dh: RFC.uaPublic, auth: RFC.auth });
37 assert.notEqual(b64url(a), b64url(b));
38 // Header: 16 salt, record size 4096, a 65-byte key id.
39 assert.equal(new DataView(a.buffer, a.byteOffset).getUint32(16), 4096);
40 assert.equal(a[20], 65);
41 assert.equal(a[21], 4);
42 const plain = await decrypt(a, { privateKey: RFC.uaPrivate, publicKey: RFC.uaPublic, auth: RFC.auth });
43 assert.deepEqual(plain, message);
44});
45
46test("too long a message for one record is refused", async () => {
47 await assert.rejects(encrypt(new Uint8Array(4000), { p256dh: RFC.uaPublic, auth: RFC.auth }));
48});
49
50test("the VAPID JWT: ES256, the push service's origin, a 12-hour expiry, a subject, and a signature that verifies", async () => {
51 const keys = await generateVapidKeys();
52 assert.equal(fromB64url(keys.publicKey).length, 65);
53 assert.equal(fromB64url(keys.privateKey).length, 32);
54 const now = Date.UTC(2026, 9, 8, 12);
55 const jwt = await vapidJwt("https://fcm.googleapis.com/fcm/send/abc:def", { ...keys, subject: "https://g1t.sh" }, now);
56 const [header, claims, signature] = jwt.split(".");
57 assert.deepEqual(JSON.parse(new TextDecoder().decode(fromB64url(header))), { typ: "JWT", alg: "ES256" });
58 assert.deepEqual(JSON.parse(new TextDecoder().decode(fromB64url(claims))), {
59 aud: "https://fcm.googleapis.com",
60 exp: now / 1000 + 12 * 3600,
61 sub: "https://g1t.sh",
62 });
63 // Raw r‖s, as JWS wants, not DER.
64 assert.equal(fromB64url(signature).length, 64);
65 const point = fromB64url(keys.publicKey);
66 const verifier = await crypto.subtle.importKey("raw", point.slice().buffer as ArrayBuffer, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
67 const ok = await crypto.subtle.verify(
68 { name: "ECDSA", hash: "SHA-256" },
69 verifier,
70 fromB64url(signature).slice().buffer as ArrayBuffer,
71 new TextEncoder().encode(`${header}.${claims}`),
72 );
73 assert.ok(ok);
74 const authorization = await vapidAuthorization("https://push.example/x", { ...keys, subject: "https://g1t.sh" }, now);
75 assert.match(authorization, /^vapid t=[\w-]+\.[\w-]+\.[\w-]+, k=[\w-]+$/);
76});
77
78test("a push is posted encrypted with its headers; 404 and 410 mean gone", async () => {
79 const keys = await generateVapidKeys();
80 const seen: { url: string; init: RequestInit }[] = [];
81 const status = { value: 201 };
82 const fetcher = (async (url: string, init: RequestInit) => {
83 seen.push({ url, init });
84 return new Response(null, { status: status.value });
85 }) as unknown as typeof fetch;
86 const subscription = { endpoint: "https://push.example/sub/1", p256dh: RFC.uaPublic, auth: RFC.auth };
87 const sent = await sendPush(subscription, { title: "hi" }, { vapid: { ...keys, subject: "https://g1t.sh" }, urgency: "high", topic: "chat:chn_1/x" }, fetcher);
88 assert.deepEqual(sent, { endpoint: subscription.endpoint, status: 201, gone: false });
89 const headers = seen[0].init.headers as Record<string, string>;
90 assert.equal(headers["content-encoding"], "aes128gcm");
91 assert.equal(headers.urgency, "high");
92 assert.equal(headers.topic, "chatchn_1x");
93 assert.equal(headers.ttl, "86400");
94 const body = new Uint8Array(seen[0].init.body as ArrayBuffer);
95 const plain = await decrypt(body, { privateKey: RFC.uaPrivate, publicKey: RFC.uaPublic, auth: RFC.auth });
96 assert.deepEqual(JSON.parse(new TextDecoder().decode(plain)), { title: "hi" });
97 for (const code of [404, 410]) {
98 status.value = code;
99 assert.equal((await sendPush(subscription, {}, { vapid: { ...keys, subject: "https://g1t.sh" } }, fetcher)).gone, true);
100 }
101 status.value = 429;
102 assert.equal((await sendPush(subscription, {}, { vapid: { ...keys, subject: "https://g1t.sh" } }, fetcher)).gone, false);
103});

This file's history is long; its oldest lines are credited to the oldest commit read.