@dwk/websub 0.1.0-beta.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.
Files changed (63) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +155 -0
  3. package/dist/config.d.ts +122 -0
  4. package/dist/config.d.ts.map +1 -0
  5. package/dist/config.js +96 -0
  6. package/dist/config.js.map +1 -0
  7. package/dist/consumer.d.ts +39 -0
  8. package/dist/consumer.d.ts.map +1 -0
  9. package/dist/consumer.js +146 -0
  10. package/dist/consumer.js.map +1 -0
  11. package/dist/distribute.d.ts +109 -0
  12. package/dist/distribute.d.ts.map +1 -0
  13. package/dist/distribute.js +140 -0
  14. package/dist/distribute.js.map +1 -0
  15. package/dist/fetch.d.ts +28 -0
  16. package/dist/fetch.d.ts.map +1 -0
  17. package/dist/fetch.js +73 -0
  18. package/dist/fetch.js.map +1 -0
  19. package/dist/handler.d.ts +43 -0
  20. package/dist/handler.d.ts.map +1 -0
  21. package/dist/handler.js +127 -0
  22. package/dist/handler.js.map +1 -0
  23. package/dist/index.d.ts +32 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +29 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/log.d.ts +54 -0
  28. package/dist/log.d.ts.map +1 -0
  29. package/dist/log.js +52 -0
  30. package/dist/log.js.map +1 -0
  31. package/dist/queue.d.ts +38 -0
  32. package/dist/queue.d.ts.map +1 -0
  33. package/dist/queue.js +12 -0
  34. package/dist/queue.js.map +1 -0
  35. package/dist/safe-fetch.d.ts +101 -0
  36. package/dist/safe-fetch.d.ts.map +1 -0
  37. package/dist/safe-fetch.js +354 -0
  38. package/dist/safe-fetch.js.map +1 -0
  39. package/dist/store.d.ts +61 -0
  40. package/dist/store.d.ts.map +1 -0
  41. package/dist/store.js +110 -0
  42. package/dist/store.js.map +1 -0
  43. package/dist/validate.d.ts +67 -0
  44. package/dist/validate.d.ts.map +1 -0
  45. package/dist/validate.js +106 -0
  46. package/dist/validate.js.map +1 -0
  47. package/dist/verify.d.ts +85 -0
  48. package/dist/verify.d.ts.map +1 -0
  49. package/dist/verify.js +149 -0
  50. package/dist/verify.js.map +1 -0
  51. package/package.json +46 -0
  52. package/src/config.ts +199 -0
  53. package/src/consumer.ts +187 -0
  54. package/src/distribute.ts +257 -0
  55. package/src/fetch.ts +84 -0
  56. package/src/handler.ts +163 -0
  57. package/src/index.ts +98 -0
  58. package/src/log.ts +56 -0
  59. package/src/queue.ts +40 -0
  60. package/src/safe-fetch.ts +412 -0
  61. package/src/store.ts +190 -0
  62. package/src/validate.ts +179 -0
  63. package/src/verify.ts +229 -0
@@ -0,0 +1,412 @@
1
+ /**
2
+ * `@dwk/websub` — SSRF-safe outbound fetch.
3
+ *
4
+ * A WebSub hub fetches attacker-supplied URLs: the verification GET hits a
5
+ * subscriber-chosen `hub.callback`, and content distribution POSTs to every
6
+ * registered callback. Without guardrails a subscriber could point a callback at
7
+ * the Worker's own network — loopback, the link-local cloud metadata IP
8
+ * (`169.254.169.254`), or RFC 1918 ranges — to exfiltrate credentials or probe
9
+ * internal services. This module is the single choke point every outbound fetch
10
+ * in the package goes through. It:
11
+ *
12
+ * 1. rejects URLs whose host is a private, loopback, link-local, or otherwise
13
+ * non-public address (or a name like `localhost` / `*.internal`),
14
+ * 2. follows redirects manually, re-validating the host on every `Location`
15
+ * hop so a public-looking host cannot 302 to an internal one, and capping
16
+ * the hop count, and
17
+ * 3. bounds the whole operation with a single timeout, so a slow-loris callback
18
+ * cannot pin a queue-consumer invocation.
19
+ *
20
+ * Host validation is purely syntactic on the URL host — DNS rebinding (a name
21
+ * that resolves to a private IP) is out of scope here, as the Workers runtime
22
+ * does not expose name resolution to user code. See `spec/packages/websub.md`
23
+ * and `spec/non-functional-requirements.md`.
24
+ *
25
+ * @packageDocumentation
26
+ */
27
+
28
+ import { noopLogger, noopMetrics, type Logger, type Metrics } from "@dwk/log";
29
+ import type { FetchLike } from "./fetch";
30
+ import { WebSubLogEvent } from "./log";
31
+
32
+ /** Default cap on redirect hops before a fetch is abandoned. */
33
+ export const DEFAULT_MAX_REDIRECTS = 5;
34
+ /** Default overall timeout (ms) bounding a fetch, redirects included. */
35
+ export const DEFAULT_TIMEOUT_MS = 10_000;
36
+
37
+ /** HTTP status codes that carry a `Location` we may follow. */
38
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
39
+
40
+ /**
41
+ * Machine-readable cause of an {@link SsrfError}, suitable for logging as a
42
+ * structured field (no free-text parsing required).
43
+ */
44
+ export type SsrfReason =
45
+ | "invalid_url"
46
+ | "disallowed_scheme"
47
+ | "blocked_host"
48
+ | "too_many_redirects";
49
+
50
+ /**
51
+ * Raised when a request is refused on SSRF grounds (blocked host, disallowed
52
+ * scheme, or too many redirects). Callers catch this exactly like a network
53
+ * failure — a blocked attempt looks the same as an unreachable host — but
54
+ * {@link safeFetch} logs it first (event `websub.ssrf.blocked`) so the single
55
+ * most security-relevant event in the package still produces a signal.
56
+ *
57
+ * Carries the structured {@link reason} and, when known, the sanitized
58
+ * {@link host} so a logger can record them as queryable fields.
59
+ */
60
+ export class SsrfError extends Error {
61
+ /** Machine-readable cause. */
62
+ readonly reason: SsrfReason;
63
+ /** The offending host (name plus any port), when one is known. */
64
+ readonly host?: string;
65
+ constructor(message: string, reason: SsrfReason, host?: string) {
66
+ super(message);
67
+ this.name = "SsrfError";
68
+ this.reason = reason;
69
+ this.host = host;
70
+ }
71
+ }
72
+
73
+ /** Parse a canonical dotted-decimal IPv4 host into its four octets. */
74
+ function parseIPv4(host: string): [number, number, number, number] | null {
75
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
76
+ if (match === null) {
77
+ return null;
78
+ }
79
+ const octets: number[] = [];
80
+ for (let group = 1; group <= 4; group++) {
81
+ const part = match[group];
82
+ if (part === undefined) {
83
+ return null;
84
+ }
85
+ const octet = Number.parseInt(part, 10);
86
+ if (octet > 255) {
87
+ return null;
88
+ }
89
+ octets.push(octet);
90
+ }
91
+ return octets as [number, number, number, number];
92
+ }
93
+
94
+ /**
95
+ * True when `octets` falls in a range that must never be fetched from inside
96
+ * the Worker's network: this-network, loopback, link-local (incl. the cloud
97
+ * metadata IP), the RFC 1918 private blocks, CGNAT, IETF protocol/benchmark
98
+ * assignments, and the multicast/reserved/broadcast space.
99
+ */
100
+ function isPrivateIPv4(octets: [number, number, number, number]): boolean {
101
+ const [a, b, c] = octets;
102
+ if (a === 0) return true; // 0.0.0.0/8 ("this network", incl. 0.0.0.0)
103
+ if (a === 10) return true; // 10.0.0.0/8 private
104
+ if (a === 127) return true; // 127.0.0.0/8 loopback
105
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
106
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local (metadata)
107
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
108
+ if (a === 192 && b === 0 && c === 0) return true; // 192.0.0.0/24 IETF protocol
109
+ if (a === 192 && b === 0 && c === 2) return true; // 192.0.2.0/24 TEST-NET-1
110
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
111
+ if (a === 198 && b === 51 && c === 100) return true; // 198.51.100.0/24 TEST-NET-2
112
+ if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 benchmark
113
+ if (a === 203 && b === 0 && c === 113) return true; // 203.0.113.0/24 TEST-NET-3
114
+ if (a >= 224) return true; // 224.0.0.0/4 multicast + 240.0.0.0/4 reserved + broadcast
115
+ return false;
116
+ }
117
+
118
+ /**
119
+ * Parse an IPv6 host (brackets already stripped) into its eight 16-bit groups,
120
+ * expanding `::` compression and any trailing embedded IPv4 literal. Returns
121
+ * `null` when `host` is not a valid IPv6 address.
122
+ */
123
+ function parseIPv6(host: string): number[] | null {
124
+ if (!host.includes(":")) {
125
+ return null;
126
+ }
127
+ let str = host;
128
+
129
+ // Fold a trailing embedded IPv4 literal (e.g. ::ffff:127.0.0.1) into two
130
+ // hex groups so the rest can be parsed uniformly.
131
+ const v4Match = /(?:^|:)((?:\d{1,3}\.){3}\d{1,3})$/.exec(str);
132
+ const v4Str = v4Match?.[1];
133
+ if (v4Str !== undefined) {
134
+ const v4 = parseIPv4(v4Str);
135
+ if (v4 === null) {
136
+ return null;
137
+ }
138
+ const hi = ((v4[0] << 8) | v4[1]).toString(16);
139
+ const lo = ((v4[2] << 8) | v4[3]).toString(16);
140
+ str = `${str.slice(0, str.length - v4Str.length)}${hi}:${lo}`;
141
+ }
142
+
143
+ // At most one "::" compression marker is allowed.
144
+ if (str.indexOf("::") !== str.lastIndexOf("::")) {
145
+ return null;
146
+ }
147
+
148
+ const toGroups = (part: string): number[] | null => {
149
+ if (part === "") {
150
+ return [];
151
+ }
152
+ const groups: number[] = [];
153
+ for (const token of part.split(":")) {
154
+ if (!/^[0-9a-fA-F]{1,4}$/.test(token)) {
155
+ return null;
156
+ }
157
+ groups.push(Number.parseInt(token, 16));
158
+ }
159
+ return groups;
160
+ };
161
+
162
+ if (str.includes("::")) {
163
+ const parts = str.split("::");
164
+ const left = toGroups(parts[0] ?? "");
165
+ const right = toGroups(parts[1] ?? "");
166
+ if (left === null || right === null) {
167
+ return null;
168
+ }
169
+ const missing = 8 - left.length - right.length;
170
+ if (missing < 1) {
171
+ return null;
172
+ }
173
+ return [...left, ...new Array<number>(missing).fill(0), ...right];
174
+ }
175
+
176
+ const all = toGroups(str);
177
+ if (all === null || all.length !== 8) {
178
+ return null;
179
+ }
180
+ return all;
181
+ }
182
+
183
+ /**
184
+ * True when `groups` (eight 16-bit values) is an IPv6 address that must never
185
+ * be fetched: unspecified, loopback, link-local, site-local, unique-local,
186
+ * multicast, the documentation prefix, or an address that embeds an IPv4
187
+ * (IPv4-mapped `::ffff:0:0/96`, deprecated IPv4-compatible `::/96`, or NAT64
188
+ * `64:ff9b::/96`) whose embedded IPv4 is itself private.
189
+ */
190
+ function isPrivateIPv6(groups: number[]): boolean {
191
+ const first = groups[0] ?? 0;
192
+ const g6 = groups[6] ?? 0;
193
+ const g7 = groups[7] ?? 0;
194
+ if (groups.every((group) => group === 0)) return true; // :: unspecified
195
+ if (groups.slice(0, 7).every((group) => group === 0) && g7 === 1) return true; // ::1 loopback
196
+ if ((first & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
197
+ if ((first & 0xffc0) === 0xfec0) return true; // fec0::/10 site-local (deprecated)
198
+ if ((first & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local
199
+ if ((first & 0xff00) === 0xff00) return true; // ff00::/8 multicast
200
+ if (first === 0x2001 && groups[1] === 0x0db8) return true; // 2001:db8::/32 documentation
201
+
202
+ // Extract the IPv4 embedded in the low 32 bits.
203
+ const embeddedV4: [number, number, number, number] = [
204
+ g6 >> 8,
205
+ g6 & 0xff,
206
+ g7 >> 8,
207
+ g7 & 0xff,
208
+ ];
209
+ // ::ffff:0:0/96 IPv4-mapped and ::/96 deprecated IPv4-compatible.
210
+ if (
211
+ groups.slice(0, 5).every((group) => group === 0) &&
212
+ (groups[5] === 0xffff || groups[5] === 0x0000)
213
+ ) {
214
+ return isPrivateIPv4(embeddedV4);
215
+ }
216
+ // 64:ff9b::/96 NAT64 well-known prefix.
217
+ if (
218
+ first === 0x0064 &&
219
+ groups[1] === 0xff9b &&
220
+ groups.slice(2, 6).every((group) => group === 0)
221
+ ) {
222
+ return isPrivateIPv4(embeddedV4);
223
+ }
224
+ return false;
225
+ }
226
+
227
+ /** Hostnames (non-IP) that are never public and must never be fetched. */
228
+ function isBlockedHostname(host: string): boolean {
229
+ const lower = host.toLowerCase();
230
+ return (
231
+ lower === "localhost" ||
232
+ lower.endsWith(".localhost") ||
233
+ lower.endsWith(".local") ||
234
+ lower.endsWith(".internal")
235
+ );
236
+ }
237
+
238
+ /**
239
+ * Decide whether a URL host is private, loopback, link-local, or otherwise
240
+ * not safe to fetch from inside the Worker's network. Accepts the raw
241
+ * `URL.hostname` form (IPv6 hosts may arrive wrapped in `[...]`).
242
+ */
243
+ export function isPrivateOrReservedHost(hostname: string): boolean {
244
+ if (hostname === "") {
245
+ return true;
246
+ }
247
+ // Strip IPv6 brackets and a trailing dot. A trailing dot makes a name an
248
+ // FQDN that still resolves (e.g. `localhost.` → 127.0.0.1) but would slip
249
+ // past the string checks below if left in place.
250
+ const host = (
251
+ hostname.startsWith("[") && hostname.endsWith("]")
252
+ ? hostname.slice(1, -1)
253
+ : hostname
254
+ ).replace(/\.$/, "");
255
+
256
+ const v4 = parseIPv4(host);
257
+ if (v4 !== null) {
258
+ return isPrivateIPv4(v4);
259
+ }
260
+ const v6 = parseIPv6(host);
261
+ if (v6 !== null) {
262
+ return isPrivateIPv6(v6);
263
+ }
264
+ return isBlockedHostname(host);
265
+ }
266
+
267
+ /**
268
+ * Validate that `rawUrl` is a fetchable public `http(s)` URL, returning the
269
+ * parsed {@link URL}. Throws {@link SsrfError} for an unparseable URL, a
270
+ * non-`http(s)` scheme (e.g. `file:`, `javascript:`), or a private/reserved
271
+ * host.
272
+ */
273
+ export function assertPublicUrl(rawUrl: string): URL {
274
+ let url: URL;
275
+ try {
276
+ url = new URL(rawUrl);
277
+ } catch {
278
+ throw new SsrfError(`invalid URL: ${rawUrl}`, "invalid_url");
279
+ }
280
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
281
+ throw new SsrfError(
282
+ `disallowed scheme: ${url.protocol}`,
283
+ "disallowed_scheme",
284
+ url.hostname,
285
+ );
286
+ }
287
+ if (isPrivateOrReservedHost(url.hostname)) {
288
+ throw new SsrfError(
289
+ `blocked host: ${url.hostname}`,
290
+ "blocked_host",
291
+ url.hostname,
292
+ );
293
+ }
294
+ return url;
295
+ }
296
+
297
+ /** Tunables for {@link safeFetch}. */
298
+ export interface SafeFetchOptions {
299
+ /** Maximum redirect hops to follow (default {@link DEFAULT_MAX_REDIRECTS}). */
300
+ readonly maxRedirects?: number;
301
+ /** Overall timeout in ms, redirects included (default {@link DEFAULT_TIMEOUT_MS}). */
302
+ readonly timeoutMs?: number;
303
+ /** Logger for SSRF blocks; defaults to a no-op (see `@dwk/log`). */
304
+ readonly logger?: Logger;
305
+ /** Metrics sink for SSRF-block counters; defaults to a no-op (see `@dwk/log`). */
306
+ readonly metrics?: Metrics;
307
+ }
308
+
309
+ /** A completed {@link safeFetch}: the final response and the URL it came from. */
310
+ export interface SafeFetchResult {
311
+ /** The final, non-redirect response. */
312
+ readonly response: Response;
313
+ /** The fully-resolved URL the response came from. */
314
+ readonly url: string;
315
+ }
316
+
317
+ /**
318
+ * Fetch `rawUrl` through `doFetch` with SSRF guardrails.
319
+ *
320
+ * The initial host and every redirect target are validated with
321
+ * {@link assertPublicUrl}; redirects are followed manually (`redirect:
322
+ * "manual"`) up to `maxRedirects` hops; and a single {@link AbortSignal.timeout}
323
+ * bounds the whole chain. The request method, headers, and body from `init` are
324
+ * preserved across hops — a redirected `POST` notification re-POSTs to the
325
+ * (re-validated) new location rather than silently degrading to `GET`.
326
+ *
327
+ * @throws {SsrfError} when a host is blocked, a scheme is disallowed, or the
328
+ * redirect cap is exceeded. Other failures (network, timeout) propagate as the
329
+ * underlying fetch rejection. Callers treat any throw as "fetch failed".
330
+ */
331
+ export async function safeFetch(
332
+ doFetch: FetchLike,
333
+ rawUrl: string,
334
+ init: RequestInit,
335
+ options?: SafeFetchOptions,
336
+ ): Promise<SafeFetchResult> {
337
+ const maxRedirects = options?.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
338
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
339
+ const logger = options?.logger ?? noopLogger;
340
+ const metrics = options?.metrics ?? noopMetrics;
341
+ // Bound the chain with our own timeout, but don't clobber a caller's signal
342
+ // (e.g. a worker-shutdown abort): combine them so either can cancel.
343
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
344
+ const signal =
345
+ init.signal != null
346
+ ? AbortSignal.any([init.signal, timeoutSignal])
347
+ : timeoutSignal;
348
+
349
+ // A blocked request is the single most security-relevant event here, so log
350
+ // it (with its structured reason + sanitized host) before re-throwing — an
351
+ // operator being actively probed sees a distinct signal instead of silence.
352
+ try {
353
+ let currentUrl = assertPublicUrl(rawUrl).toString();
354
+ let currentInit: RequestInit = { ...init };
355
+ for (let hop = 0; ; hop++) {
356
+ const response = await doFetch(currentUrl, {
357
+ ...currentInit,
358
+ redirect: "manual",
359
+ signal,
360
+ });
361
+
362
+ if (!REDIRECT_STATUSES.has(response.status)) {
363
+ return { response, url: currentUrl };
364
+ }
365
+
366
+ const location = response.headers.get("location");
367
+ if (location === null || location === "") {
368
+ // A redirect with nothing to follow — hand back as the final response.
369
+ return { response, url: currentUrl };
370
+ }
371
+ if (hop >= maxRedirects) {
372
+ throw new SsrfError(
373
+ `too many redirects (> ${maxRedirects})`,
374
+ "too_many_redirects",
375
+ new URL(currentUrl).host,
376
+ );
377
+ }
378
+
379
+ // Resolve the next hop against the current URL and re-validate its host
380
+ // before following — a public host must not be able to bounce us inward.
381
+ const next = assertPublicUrl(new URL(location, currentUrl).toString());
382
+ // Drain the redirect body so the connection can be reused/closed.
383
+ await response.body?.cancel().catch(() => undefined);
384
+
385
+ // Strip credential-bearing headers on a cross-origin hop, matching what a
386
+ // browser's `fetch` does, so a redirect cannot leak them to a new origin.
387
+ if (currentInit.headers && new URL(currentUrl).origin !== next.origin) {
388
+ const headers = new Headers(currentInit.headers as HeadersInit);
389
+ for (const name of [
390
+ "authorization",
391
+ "cookie",
392
+ "cookie2",
393
+ "proxy-authorization",
394
+ "set-cookie",
395
+ "x-hub-signature",
396
+ ]) {
397
+ headers.delete(name);
398
+ }
399
+ currentInit = { ...currentInit, headers };
400
+ }
401
+ currentUrl = next.toString();
402
+ }
403
+ } catch (err) {
404
+ if (err instanceof SsrfError) {
405
+ const fields = { reason: err.reason, host: err.host };
406
+ logger.warn(WebSubLogEvent.SsrfBlocked, fields);
407
+ // Mirror the log as a counter so "SSRF blocks/min by reason" is chartable.
408
+ metrics.count(WebSubLogEvent.SsrfBlocked, fields);
409
+ }
410
+ throw err;
411
+ }
412
+ }
package/src/store.ts ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * `@dwk/websub` — D1-backed subscription store.
3
+ *
4
+ * The set of active subscriptions is authoritative state: a stale or lost row
5
+ * means a subscriber stops receiving pushes (or keeps receiving them past lease
6
+ * expiry), which is a correctness bug, not a safe-to-be-stale cache. It therefore
7
+ * lives in **D1 (strongly consistent), never KV** — see
8
+ * `spec/non-functional-requirements.md`. A subscription is keyed on the
9
+ * `(callback, topic)` pair so re-subscribing renews the lease in place. Rows are
10
+ * written only **after** intent verification succeeds. See
11
+ * `spec/packages/websub.md`.
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+
16
+ import type { D1Database } from "@cloudflare/workers-types";
17
+
18
+ /** A verified, active subscription. */
19
+ export interface Subscription {
20
+ readonly callback: string;
21
+ readonly topic: string;
22
+ /** HMAC secret to sign deliveries with, or `null` when none was registered. */
23
+ readonly secret: string | null;
24
+ /** Granted lease length, in seconds. */
25
+ readonly leaseSeconds: number;
26
+ /** Lease expiry, epoch milliseconds. After this the subscription is dead. */
27
+ readonly expiresAt: number;
28
+ /** Creation/last-renewal time, epoch milliseconds. */
29
+ readonly createdAt: number;
30
+ }
31
+
32
+ /** Fields needed to upsert (create or renew) a subscription. */
33
+ export interface SubscriptionUpsert {
34
+ readonly callback: string;
35
+ readonly topic: string;
36
+ readonly secret?: string | undefined;
37
+ readonly leaseSeconds: number;
38
+ /** Now, epoch milliseconds; `expiresAt` is derived as `now + leaseSeconds*1000`. */
39
+ readonly now: number;
40
+ }
41
+
42
+ /** Persistence surface for subscriptions. */
43
+ export interface SubscriptionStore {
44
+ /** Upsert (create or renew) a verified subscription, keyed on `(callback, topic)`. */
45
+ upsert(subscription: SubscriptionUpsert): Promise<void>;
46
+ /** Remove a subscription; no-op when absent. */
47
+ remove(callback: string, topic: string): Promise<void>;
48
+ /** List subscriptions for `topic` that are still within their lease at `now`. */
49
+ listActive(topic: string, now: number): Promise<Subscription[]>;
50
+ /** Look up one subscription, or `null` when absent. */
51
+ get(callback: string, topic: string): Promise<Subscription | null>;
52
+ /** Delete every subscription whose lease expired at or before `now`; returns the count removed. */
53
+ pruneExpired(now: number): Promise<number>;
54
+ }
55
+
56
+ /** Options for {@link createD1SubscriptionStore}. */
57
+ export interface D1StoreOptions {
58
+ /** Table name to use; created if absent. Defaults to `websub_subscriptions`. */
59
+ readonly table?: string;
60
+ }
61
+
62
+ interface SubscriptionRow {
63
+ readonly callback: string;
64
+ readonly topic: string;
65
+ readonly secret: string | null;
66
+ readonly lease_seconds: number;
67
+ readonly expires_at: number;
68
+ readonly created_at: number;
69
+ }
70
+
71
+ function rowToSubscription(row: SubscriptionRow): Subscription {
72
+ return {
73
+ callback: row.callback,
74
+ topic: row.topic,
75
+ secret: row.secret,
76
+ leaseSeconds: row.lease_seconds,
77
+ expiresAt: row.expires_at,
78
+ createdAt: row.created_at,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Build a D1-backed {@link SubscriptionStore}. The backing table is created on
84
+ * first use if it does not already exist.
85
+ */
86
+ export function createD1SubscriptionStore(
87
+ db: D1Database,
88
+ options?: D1StoreOptions,
89
+ ): SubscriptionStore {
90
+ const table = options?.table ?? "websub_subscriptions";
91
+ // Guard the identifier: it is interpolated into DDL, so only allow a safe
92
+ // set of characters rather than trusting the caller blindly.
93
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) {
94
+ throw new Error(`@dwk/websub: invalid subscription table name "${table}".`);
95
+ }
96
+
97
+ let ready: Promise<void> | null = null;
98
+ const ensureSchema = (): Promise<void> => {
99
+ // Clear the cached promise on failure so a transient D1 error during the
100
+ // first call doesn't permanently wedge the store: a later operation retries
101
+ // the DDL instead of inheriting the cached rejection.
102
+ ready ??= db
103
+ .prepare(
104
+ `CREATE TABLE IF NOT EXISTS ${table} (` +
105
+ `callback TEXT NOT NULL, ` +
106
+ `topic TEXT NOT NULL, ` +
107
+ `secret TEXT, ` +
108
+ `lease_seconds INTEGER NOT NULL, ` +
109
+ `expires_at INTEGER NOT NULL, ` +
110
+ `created_at INTEGER NOT NULL, ` +
111
+ `PRIMARY KEY (callback, topic))`,
112
+ )
113
+ .run()
114
+ .then(() => undefined)
115
+ .catch((err: unknown) => {
116
+ ready = null;
117
+ throw err;
118
+ });
119
+ return ready;
120
+ };
121
+
122
+ return {
123
+ async upsert(subscription) {
124
+ await ensureSchema();
125
+ const expiresAt = subscription.now + subscription.leaseSeconds * 1000;
126
+ await db
127
+ .prepare(
128
+ `INSERT INTO ${table} ` +
129
+ `(callback, topic, secret, lease_seconds, expires_at, created_at) ` +
130
+ `VALUES (?1, ?2, ?3, ?4, ?5, ?6) ` +
131
+ `ON CONFLICT (callback, topic) DO UPDATE SET ` +
132
+ `secret = excluded.secret, ` +
133
+ `lease_seconds = excluded.lease_seconds, ` +
134
+ `expires_at = excluded.expires_at, ` +
135
+ `created_at = excluded.created_at`,
136
+ )
137
+ .bind(
138
+ subscription.callback,
139
+ subscription.topic,
140
+ subscription.secret ?? null,
141
+ subscription.leaseSeconds,
142
+ expiresAt,
143
+ subscription.now,
144
+ )
145
+ .run();
146
+ },
147
+
148
+ async remove(callback, topic) {
149
+ await ensureSchema();
150
+ await db
151
+ .prepare(`DELETE FROM ${table} WHERE callback = ?1 AND topic = ?2`)
152
+ .bind(callback, topic)
153
+ .run();
154
+ },
155
+
156
+ async listActive(topic, now) {
157
+ await ensureSchema();
158
+ const { results } = await db
159
+ .prepare(
160
+ `SELECT callback, topic, secret, lease_seconds, expires_at, created_at ` +
161
+ `FROM ${table} WHERE topic = ?1 AND expires_at > ?2 ` +
162
+ `ORDER BY created_at ASC`,
163
+ )
164
+ .bind(topic, now)
165
+ .all<SubscriptionRow>();
166
+ return results.map(rowToSubscription);
167
+ },
168
+
169
+ async get(callback, topic) {
170
+ await ensureSchema();
171
+ const row = await db
172
+ .prepare(
173
+ `SELECT callback, topic, secret, lease_seconds, expires_at, created_at ` +
174
+ `FROM ${table} WHERE callback = ?1 AND topic = ?2`,
175
+ )
176
+ .bind(callback, topic)
177
+ .first<SubscriptionRow>();
178
+ return row === null ? null : rowToSubscription(row);
179
+ },
180
+
181
+ async pruneExpired(now) {
182
+ await ensureSchema();
183
+ const result = await db
184
+ .prepare(`DELETE FROM ${table} WHERE expires_at <= ?1`)
185
+ .bind(now)
186
+ .run();
187
+ return result.meta.changes ?? 0;
188
+ },
189
+ };
190
+ }