@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,61 @@
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
+ import type { D1Database } from "@cloudflare/workers-types";
16
+ /** A verified, active subscription. */
17
+ export interface Subscription {
18
+ readonly callback: string;
19
+ readonly topic: string;
20
+ /** HMAC secret to sign deliveries with, or `null` when none was registered. */
21
+ readonly secret: string | null;
22
+ /** Granted lease length, in seconds. */
23
+ readonly leaseSeconds: number;
24
+ /** Lease expiry, epoch milliseconds. After this the subscription is dead. */
25
+ readonly expiresAt: number;
26
+ /** Creation/last-renewal time, epoch milliseconds. */
27
+ readonly createdAt: number;
28
+ }
29
+ /** Fields needed to upsert (create or renew) a subscription. */
30
+ export interface SubscriptionUpsert {
31
+ readonly callback: string;
32
+ readonly topic: string;
33
+ readonly secret?: string | undefined;
34
+ readonly leaseSeconds: number;
35
+ /** Now, epoch milliseconds; `expiresAt` is derived as `now + leaseSeconds*1000`. */
36
+ readonly now: number;
37
+ }
38
+ /** Persistence surface for subscriptions. */
39
+ export interface SubscriptionStore {
40
+ /** Upsert (create or renew) a verified subscription, keyed on `(callback, topic)`. */
41
+ upsert(subscription: SubscriptionUpsert): Promise<void>;
42
+ /** Remove a subscription; no-op when absent. */
43
+ remove(callback: string, topic: string): Promise<void>;
44
+ /** List subscriptions for `topic` that are still within their lease at `now`. */
45
+ listActive(topic: string, now: number): Promise<Subscription[]>;
46
+ /** Look up one subscription, or `null` when absent. */
47
+ get(callback: string, topic: string): Promise<Subscription | null>;
48
+ /** Delete every subscription whose lease expired at or before `now`; returns the count removed. */
49
+ pruneExpired(now: number): Promise<number>;
50
+ }
51
+ /** Options for {@link createD1SubscriptionStore}. */
52
+ export interface D1StoreOptions {
53
+ /** Table name to use; created if absent. Defaults to `websub_subscriptions`. */
54
+ readonly table?: string;
55
+ }
56
+ /**
57
+ * Build a D1-backed {@link SubscriptionStore}. The backing table is created on
58
+ * first use if it does not already exist.
59
+ */
60
+ export declare function createD1SubscriptionStore(db: D1Database, options?: D1StoreOptions): SubscriptionStore;
61
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAE5D,uCAAuC;AACvC,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,wCAAwC;IACxC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,6EAA6E;IAC7E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,sDAAsD;IACtD,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,gEAAgE;AAChE,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACrC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,oFAAoF;IACpF,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED,6CAA6C;AAC7C,MAAM,WAAW,iBAAiB;IAChC,sFAAsF;IACtF,MAAM,CAAC,YAAY,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxD,gDAAgD;IAChD,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvD,iFAAiF;IACjF,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC;IAChE,uDAAuD;IACvD,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;IACnE,mGAAmG;IACnG,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CAC5C;AAED,qDAAqD;AACrD,MAAM,WAAW,cAAc;IAC7B,gFAAgF;IAChF,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAsBD;;;GAGG;AACH,wBAAgB,yBAAyB,CACvC,EAAE,EAAE,UAAU,EACd,OAAO,CAAC,EAAE,cAAc,GACvB,iBAAiB,CAqGnB"}
package/dist/store.js ADDED
@@ -0,0 +1,110 @@
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
+ function rowToSubscription(row) {
16
+ return {
17
+ callback: row.callback,
18
+ topic: row.topic,
19
+ secret: row.secret,
20
+ leaseSeconds: row.lease_seconds,
21
+ expiresAt: row.expires_at,
22
+ createdAt: row.created_at,
23
+ };
24
+ }
25
+ /**
26
+ * Build a D1-backed {@link SubscriptionStore}. The backing table is created on
27
+ * first use if it does not already exist.
28
+ */
29
+ export function createD1SubscriptionStore(db, options) {
30
+ const table = options?.table ?? "websub_subscriptions";
31
+ // Guard the identifier: it is interpolated into DDL, so only allow a safe
32
+ // set of characters rather than trusting the caller blindly.
33
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) {
34
+ throw new Error(`@dwk/websub: invalid subscription table name "${table}".`);
35
+ }
36
+ let ready = null;
37
+ const ensureSchema = () => {
38
+ // Clear the cached promise on failure so a transient D1 error during the
39
+ // first call doesn't permanently wedge the store: a later operation retries
40
+ // the DDL instead of inheriting the cached rejection.
41
+ ready ??= db
42
+ .prepare(`CREATE TABLE IF NOT EXISTS ${table} (` +
43
+ `callback TEXT NOT NULL, ` +
44
+ `topic TEXT NOT NULL, ` +
45
+ `secret TEXT, ` +
46
+ `lease_seconds INTEGER NOT NULL, ` +
47
+ `expires_at INTEGER NOT NULL, ` +
48
+ `created_at INTEGER NOT NULL, ` +
49
+ `PRIMARY KEY (callback, topic))`)
50
+ .run()
51
+ .then(() => undefined)
52
+ .catch((err) => {
53
+ ready = null;
54
+ throw err;
55
+ });
56
+ return ready;
57
+ };
58
+ return {
59
+ async upsert(subscription) {
60
+ await ensureSchema();
61
+ const expiresAt = subscription.now + subscription.leaseSeconds * 1000;
62
+ await db
63
+ .prepare(`INSERT INTO ${table} ` +
64
+ `(callback, topic, secret, lease_seconds, expires_at, created_at) ` +
65
+ `VALUES (?1, ?2, ?3, ?4, ?5, ?6) ` +
66
+ `ON CONFLICT (callback, topic) DO UPDATE SET ` +
67
+ `secret = excluded.secret, ` +
68
+ `lease_seconds = excluded.lease_seconds, ` +
69
+ `expires_at = excluded.expires_at, ` +
70
+ `created_at = excluded.created_at`)
71
+ .bind(subscription.callback, subscription.topic, subscription.secret ?? null, subscription.leaseSeconds, expiresAt, subscription.now)
72
+ .run();
73
+ },
74
+ async remove(callback, topic) {
75
+ await ensureSchema();
76
+ await db
77
+ .prepare(`DELETE FROM ${table} WHERE callback = ?1 AND topic = ?2`)
78
+ .bind(callback, topic)
79
+ .run();
80
+ },
81
+ async listActive(topic, now) {
82
+ await ensureSchema();
83
+ const { results } = await db
84
+ .prepare(`SELECT callback, topic, secret, lease_seconds, expires_at, created_at ` +
85
+ `FROM ${table} WHERE topic = ?1 AND expires_at > ?2 ` +
86
+ `ORDER BY created_at ASC`)
87
+ .bind(topic, now)
88
+ .all();
89
+ return results.map(rowToSubscription);
90
+ },
91
+ async get(callback, topic) {
92
+ await ensureSchema();
93
+ const row = await db
94
+ .prepare(`SELECT callback, topic, secret, lease_seconds, expires_at, created_at ` +
95
+ `FROM ${table} WHERE callback = ?1 AND topic = ?2`)
96
+ .bind(callback, topic)
97
+ .first();
98
+ return row === null ? null : rowToSubscription(row);
99
+ },
100
+ async pruneExpired(now) {
101
+ await ensureSchema();
102
+ const result = await db
103
+ .prepare(`DELETE FROM ${table} WHERE expires_at <= ?1`)
104
+ .bind(now)
105
+ .run();
106
+ return result.meta.changes ?? 0;
107
+ },
108
+ };
109
+ }
110
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `@dwk/websub` — request validation.
3
+ *
4
+ * Subscribe/unsubscribe and publish requests are validated synchronously, before
5
+ * any slow work is enqueued, so a malformed request gets a fast `400` and never
6
+ * reaches the queue. Validation is pure (no I/O) and unit-tests without a Workers
7
+ * runtime. Stable, machine-readable error codes are returned in place of free
8
+ * text. See `spec/packages/websub.md` and WebSub §5–§7.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+ import type { ResolvedConfig } from "./config";
13
+ /** WebSub §6.1.1: a `hub.secret` MUST be less than 200 bytes. */
14
+ export declare const MAX_SECRET_BYTES = 200;
15
+ /** Machine-readable rejection codes for a subscribe/unsubscribe request. */
16
+ export type SubscribeError = "invalid_mode" | "callback_required" | "callback_not_url" | "topic_required" | "topic_not_url" | "topic_not_supported" | "secret_too_long" | "invalid_lease_seconds";
17
+ /** Machine-readable rejection codes for a publish request. */
18
+ export type PublishError = "url_required" | "url_not_url" | "topic_not_supported";
19
+ /** Parsed, validated subscribe/unsubscribe request. */
20
+ export interface SubscribeRequest {
21
+ readonly ok: true;
22
+ readonly mode: "subscribe" | "unsubscribe";
23
+ readonly callback: string;
24
+ readonly topic: string;
25
+ /** Requested lease, clamped into the hub's bounds; `undefined` left to the consumer. */
26
+ readonly leaseSeconds: number;
27
+ readonly secret?: string;
28
+ }
29
+ /** Parsed, validated publish request. */
30
+ export interface PublishRequest {
31
+ readonly ok: true;
32
+ readonly mode: "publish";
33
+ readonly topic: string;
34
+ }
35
+ /** Result of validating a subscribe/unsubscribe request. */
36
+ export type SubscribeResult = SubscribeRequest | {
37
+ readonly ok: false;
38
+ readonly error: SubscribeError;
39
+ };
40
+ /** Result of validating a publish request. */
41
+ export type PublishResult = PublishRequest | {
42
+ readonly ok: false;
43
+ readonly error: PublishError;
44
+ };
45
+ /** The raw `hub.*` fields lifted out of a form body. */
46
+ export interface RawHubParams {
47
+ readonly mode: string | null;
48
+ readonly callback: string | null;
49
+ readonly topic: string | null;
50
+ readonly url: string | null;
51
+ readonly leaseSeconds: string | null;
52
+ readonly secret: string | null;
53
+ }
54
+ /** Lift the `hub.*` fields out of a parsed form body. */
55
+ export declare function readHubParams(form: URLSearchParams): RawHubParams;
56
+ /**
57
+ * Validate a subscribe/unsubscribe request against `config`. Clamps a supplied
58
+ * `hub.lease_seconds` into the hub's bounds and falls back to the default lease
59
+ * when it is absent.
60
+ */
61
+ export declare function validateSubscribe(params: RawHubParams, config: ResolvedConfig): SubscribeResult;
62
+ /**
63
+ * Validate a publish request. Accepts the topic in `hub.url` (WebSub §7) and
64
+ * falls back to `hub.topic` for compatibility with older publishers.
65
+ */
66
+ export declare function validatePublish(params: RawHubParams, config: ResolvedConfig): PublishResult;
67
+ //# sourceMappingURL=validate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAE/C,iEAAiE;AACjE,eAAO,MAAM,gBAAgB,MAAM,CAAC;AAEpC,4EAA4E;AAC5E,MAAM,MAAM,cAAc,GACtB,cAAc,GACd,mBAAmB,GACnB,kBAAkB,GAClB,gBAAgB,GAChB,eAAe,GACf,qBAAqB,GACrB,iBAAiB,GACjB,uBAAuB,CAAC;AAE5B,8DAA8D;AAC9D,MAAM,MAAM,YAAY,GACpB,cAAc,GACd,aAAa,GACb,qBAAqB,CAAC;AAE1B,uDAAuD;AACvD,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAClB,QAAQ,CAAC,IAAI,EAAE,WAAW,GAAG,aAAa,CAAC;IAC3C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,wFAAwF;IACxF,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,yCAAyC;AACzC,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAClB,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,4DAA4D;AAC5D,MAAM,MAAM,eAAe,GACvB,gBAAgB,GAChB;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAA;CAAE,CAAC;AAE3D,8CAA8C;AAC9C,MAAM,MAAM,aAAa,GACrB,cAAc,GACd;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAA;CAAE,CAAC;AAgBzD,wDAAwD;AACxD,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IACrC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC;AAED,yDAAyD;AACzD,wBAAgB,aAAa,CAAC,IAAI,EAAE,eAAe,GAAG,YAAY,CASjE;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,YAAY,EACpB,MAAM,EAAE,cAAc,GACrB,eAAe,CAmDjB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,YAAY,EACpB,MAAM,EAAE,cAAc,GACrB,aAAa,CAYf"}
@@ -0,0 +1,106 @@
1
+ /**
2
+ * `@dwk/websub` — request validation.
3
+ *
4
+ * Subscribe/unsubscribe and publish requests are validated synchronously, before
5
+ * any slow work is enqueued, so a malformed request gets a fast `400` and never
6
+ * reaches the queue. Validation is pure (no I/O) and unit-tests without a Workers
7
+ * runtime. Stable, machine-readable error codes are returned in place of free
8
+ * text. See `spec/packages/websub.md` and WebSub §5–§7.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+ /** WebSub §6.1.1: a `hub.secret` MUST be less than 200 bytes. */
13
+ export const MAX_SECRET_BYTES = 200;
14
+ function isHttpUrl(value) {
15
+ let url;
16
+ try {
17
+ url = new URL(value);
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ return url.protocol === "http:" || url.protocol === "https:";
23
+ }
24
+ function utf8Length(value) {
25
+ return new TextEncoder().encode(value).length;
26
+ }
27
+ /** Lift the `hub.*` fields out of a parsed form body. */
28
+ export function readHubParams(form) {
29
+ return {
30
+ mode: form.get("hub.mode"),
31
+ callback: form.get("hub.callback"),
32
+ topic: form.get("hub.topic"),
33
+ url: form.get("hub.url"),
34
+ leaseSeconds: form.get("hub.lease_seconds"),
35
+ secret: form.get("hub.secret"),
36
+ };
37
+ }
38
+ /**
39
+ * Validate a subscribe/unsubscribe request against `config`. Clamps a supplied
40
+ * `hub.lease_seconds` into the hub's bounds and falls back to the default lease
41
+ * when it is absent.
42
+ */
43
+ export function validateSubscribe(params, config) {
44
+ if (params.mode !== "subscribe" && params.mode !== "unsubscribe") {
45
+ return { ok: false, error: "invalid_mode" };
46
+ }
47
+ if (params.callback === null || params.callback === "") {
48
+ return { ok: false, error: "callback_required" };
49
+ }
50
+ if (!isHttpUrl(params.callback)) {
51
+ return { ok: false, error: "callback_not_url" };
52
+ }
53
+ if (params.topic === null || params.topic === "") {
54
+ return { ok: false, error: "topic_required" };
55
+ }
56
+ if (!isHttpUrl(params.topic)) {
57
+ return { ok: false, error: "topic_not_url" };
58
+ }
59
+ if (!config.isAllowedTopic(params.topic)) {
60
+ return { ok: false, error: "topic_not_supported" };
61
+ }
62
+ let secret;
63
+ if (params.secret !== null && params.secret !== "") {
64
+ if (utf8Length(params.secret) >= MAX_SECRET_BYTES) {
65
+ return { ok: false, error: "secret_too_long" };
66
+ }
67
+ secret = params.secret;
68
+ }
69
+ let leaseSeconds = config.defaultLeaseSeconds;
70
+ if (params.leaseSeconds !== null && params.leaseSeconds !== "") {
71
+ const parsed = Number.parseInt(params.leaseSeconds, 10);
72
+ if (!Number.isFinite(parsed)) {
73
+ return { ok: false, error: "invalid_lease_seconds" };
74
+ }
75
+ // WebSub §5.1 treats `hub.lease_seconds` as a *request* the hub may clamp,
76
+ // not a hard constraint, so a `0` (or negative) request is clamped up to the
77
+ // hub minimum rather than rejected — the hub controls the granted lease.
78
+ leaseSeconds = Math.min(Math.max(parsed, config.minLeaseSeconds), config.maxLeaseSeconds);
79
+ }
80
+ return {
81
+ ok: true,
82
+ mode: params.mode,
83
+ callback: params.callback,
84
+ topic: params.topic,
85
+ leaseSeconds,
86
+ ...(secret !== undefined ? { secret } : {}),
87
+ };
88
+ }
89
+ /**
90
+ * Validate a publish request. Accepts the topic in `hub.url` (WebSub §7) and
91
+ * falls back to `hub.topic` for compatibility with older publishers.
92
+ */
93
+ export function validatePublish(params, config) {
94
+ const topic = params.url ?? params.topic;
95
+ if (topic === null || topic === "") {
96
+ return { ok: false, error: "url_required" };
97
+ }
98
+ if (!isHttpUrl(topic)) {
99
+ return { ok: false, error: "url_not_url" };
100
+ }
101
+ if (!config.isAllowedTopic(topic)) {
102
+ return { ok: false, error: "topic_not_supported" };
103
+ }
104
+ return { ok: true, mode: "publish", topic };
105
+ }
106
+ //# sourceMappingURL=validate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.js","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `@dwk/websub` — verification of intent.
3
+ *
4
+ * Before a hub acts on a subscribe/unsubscribe request it confirms the request
5
+ * was genuinely intended by the owner of the callback URL (WebSub §5.3): it
6
+ * issues a `GET` to the callback carrying a random `hub.challenge`, and the
7
+ * subscriber confirms by echoing that exact challenge back in a `2xx` response
8
+ * body. This stops an attacker from signing up (or tearing down) a third party's
9
+ * callback. The GET goes through {@link safeFetch} so a callback can't point the
10
+ * hub at its own network. See `spec/packages/websub.md`.
11
+ *
12
+ * @packageDocumentation
13
+ */
14
+ import { type Logger, type Metrics } from "@dwk/log";
15
+ import type { FetchLike } from "./fetch";
16
+ /** Inputs to {@link verifyIntent}. */
17
+ export interface VerifyIntentOptions {
18
+ readonly mode: "subscribe" | "unsubscribe";
19
+ /** Lease (seconds) advertised in the challenge; included only for subscribe. */
20
+ readonly leaseSeconds?: number;
21
+ /** `fetch` implementation; defaults to global `fetch`. */
22
+ readonly fetch?: FetchLike;
23
+ /** Challenge string to send; defaults to a fresh random value (override in tests). */
24
+ readonly challenge?: string;
25
+ readonly logger?: Logger;
26
+ readonly metrics?: Metrics;
27
+ }
28
+ /** Outcome of a verification-of-intent exchange. */
29
+ export interface VerifyIntentResult {
30
+ /** True when the callback echoed the exact challenge with a 2xx status. */
31
+ readonly confirmed: boolean;
32
+ /** The callback's HTTP status (`0` when the GET threw or was blocked). */
33
+ readonly status: number;
34
+ }
35
+ /** Generate a fresh, unguessable challenge value. */
36
+ export declare function generateChallenge(): string;
37
+ /**
38
+ * Build the verification callback URL, appending the `hub.*` query parameters to
39
+ * whatever the subscriber's callback already carries (WebSub §5.3). `hub.mode`
40
+ * and `hub.topic` echo the request; `hub.challenge` is the value the subscriber
41
+ * must return; `hub.lease_seconds` is included for a subscribe.
42
+ */
43
+ export declare function buildVerificationUrl(callback: string, params: {
44
+ mode: "subscribe" | "unsubscribe";
45
+ topic: string;
46
+ challenge: string;
47
+ leaseSeconds?: number;
48
+ }): string;
49
+ /**
50
+ * Verify a subscriber's intent for `callback` / `topic`.
51
+ *
52
+ * Issues the challenge GET through {@link safeFetch} and confirms the response is
53
+ * `2xx` with a body whose trimmed text equals the challenge. Any throw (network,
54
+ * timeout, SSRF block) is treated as "not confirmed" with status `0` — the
55
+ * caller does not distinguish a hostile callback from an unreachable one.
56
+ */
57
+ export declare function verifyIntent(callback: string, topic: string, options: VerifyIntentOptions): Promise<VerifyIntentResult>;
58
+ /** Inputs to {@link notifyDenial}. */
59
+ export interface NotifyDenialOptions {
60
+ /** Machine-readable cause echoed to the subscriber as `hub.reason` (optional). */
61
+ readonly reason?: string;
62
+ /** `fetch` implementation; defaults to global `fetch`. */
63
+ readonly fetch?: FetchLike;
64
+ readonly logger?: Logger;
65
+ readonly metrics?: Metrics;
66
+ }
67
+ /**
68
+ * Build the subscription-denial notification URL (WebSub §5.2): the subscriber's
69
+ * callback with `hub.mode=denied` and `hub.topic` appended, plus an optional
70
+ * `hub.reason`, preserving any query string the callback already carries.
71
+ */
72
+ export declare function buildDenialUrl(callback: string, params: {
73
+ topic: string;
74
+ reason?: string;
75
+ }): string;
76
+ /**
77
+ * Tell a subscriber its subscription was denied (WebSub §5.2): issue a
78
+ * best-effort `GET` to the callback with `hub.mode=denied`. Never throws — a
79
+ * denial that cannot be delivered (unreachable callback, SSRF block) is logged,
80
+ * not retried, since the subscription was never created either way. The GET goes
81
+ * through {@link safeFetch} so a hostile callback can't point the hub at its own
82
+ * network.
83
+ */
84
+ export declare function notifyDenial(callback: string, topic: string, options?: NotifyDenialOptions): Promise<void>;
85
+ //# sourceMappingURL=verify.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAIL,KAAK,MAAM,EACX,KAAK,OAAO,EACb,MAAM,UAAU,CAAC;AAClB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAUzC,sCAAsC;AACtC,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,IAAI,EAAE,WAAW,GAAG,aAAa,CAAC;IAC3C,gFAAgF;IAChF,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,0DAA0D;IAC1D,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,CAAC;IAC3B,sFAAsF;IACtF,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED,oDAAoD;AACpD,MAAM,WAAW,kBAAkB;IACjC,2EAA2E;IAC3E,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,0EAA0E;IAC1E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,qDAAqD;AACrD,wBAAgB,iBAAiB,IAAI,MAAM,CAG1C;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE;IACN,IAAI,EAAE,WAAW,GAAG,aAAa,CAAC;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,GACA,MAAM,CASR;AAED;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAChC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,kBAAkB,CAAC,CAwD7B;AAED,sCAAsC;AACtC,MAAM,WAAW,mBAAmB;IAClC,kFAAkF;IAClF,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,0DAA0D;IAC1D,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,CAAC;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,GACzC,MAAM,CAQR;AAED;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAChC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,CAAC,EAAE,mBAAmB,GAC5B,OAAO,CAAC,IAAI,CAAC,CAmCf"}
package/dist/verify.js ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `@dwk/websub` — verification of intent.
3
+ *
4
+ * Before a hub acts on a subscribe/unsubscribe request it confirms the request
5
+ * was genuinely intended by the owner of the callback URL (WebSub §5.3): it
6
+ * issues a `GET` to the callback carrying a random `hub.challenge`, and the
7
+ * subscriber confirms by echoing that exact challenge back in a `2xx` response
8
+ * body. This stops an attacker from signing up (or tearing down) a third party's
9
+ * callback. The GET goes through {@link safeFetch} so a callback can't point the
10
+ * hub at its own network. See `spec/packages/websub.md`.
11
+ *
12
+ * @packageDocumentation
13
+ */
14
+ import { hostFromUrl, noopLogger, noopMetrics, } from "@dwk/log";
15
+ import { readBytesCapped } from "./fetch";
16
+ import { WebSubLogEvent } from "./log";
17
+ import { safeFetch } from "./safe-fetch";
18
+ /** Bytes of randomness in a generated challenge (hex-encoded → twice as many chars). */
19
+ const CHALLENGE_BYTES = 24;
20
+ /** A confirming challenge echo is tiny; refuse to buffer more than this. */
21
+ const MAX_CHALLENGE_ECHO_BYTES = 8 * 1024;
22
+ /** Generate a fresh, unguessable challenge value. */
23
+ export function generateChallenge() {
24
+ const bytes = crypto.getRandomValues(new Uint8Array(CHALLENGE_BYTES));
25
+ return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
26
+ }
27
+ /**
28
+ * Build the verification callback URL, appending the `hub.*` query parameters to
29
+ * whatever the subscriber's callback already carries (WebSub §5.3). `hub.mode`
30
+ * and `hub.topic` echo the request; `hub.challenge` is the value the subscriber
31
+ * must return; `hub.lease_seconds` is included for a subscribe.
32
+ */
33
+ export function buildVerificationUrl(callback, params) {
34
+ const url = new URL(callback);
35
+ url.searchParams.append("hub.mode", params.mode);
36
+ url.searchParams.append("hub.topic", params.topic);
37
+ url.searchParams.append("hub.challenge", params.challenge);
38
+ if (params.mode === "subscribe" && params.leaseSeconds !== undefined) {
39
+ url.searchParams.append("hub.lease_seconds", String(params.leaseSeconds));
40
+ }
41
+ return url.toString();
42
+ }
43
+ /**
44
+ * Verify a subscriber's intent for `callback` / `topic`.
45
+ *
46
+ * Issues the challenge GET through {@link safeFetch} and confirms the response is
47
+ * `2xx` with a body whose trimmed text equals the challenge. Any throw (network,
48
+ * timeout, SSRF block) is treated as "not confirmed" with status `0` — the
49
+ * caller does not distinguish a hostile callback from an unreachable one.
50
+ */
51
+ export async function verifyIntent(callback, topic, options) {
52
+ const doFetch = options.fetch ?? ((input, init) => fetch(input, init));
53
+ const logger = options.logger ?? noopLogger;
54
+ const metrics = options.metrics ?? noopMetrics;
55
+ const challenge = options.challenge ?? generateChallenge();
56
+ const url = buildVerificationUrl(callback, {
57
+ mode: options.mode,
58
+ topic,
59
+ challenge,
60
+ leaseSeconds: options.leaseSeconds,
61
+ });
62
+ const finish = (confirmed, status) => {
63
+ const fields = {
64
+ mode: options.mode,
65
+ callbackHost: hostFromUrl(callback),
66
+ confirmed,
67
+ status,
68
+ };
69
+ logger.info(WebSubLogEvent.VerifyCompleted, fields);
70
+ metrics.count(WebSubLogEvent.VerifyCompleted, fields);
71
+ return { confirmed, status };
72
+ };
73
+ let response;
74
+ try {
75
+ const result = await safeFetch(doFetch, url, { method: "GET" }, { logger, metrics });
76
+ response = result.response;
77
+ }
78
+ catch (err) {
79
+ const fields = {
80
+ callbackHost: hostFromUrl(callback),
81
+ error: err instanceof Error ? err.name : "unknown",
82
+ };
83
+ logger.warn(WebSubLogEvent.VerifyFetchFailed, fields);
84
+ metrics.count(WebSubLogEvent.VerifyFetchFailed, fields);
85
+ return finish(false, 0);
86
+ }
87
+ if (!response.ok) {
88
+ await response.body?.cancel().catch(() => undefined);
89
+ return finish(false, response.status);
90
+ }
91
+ const bytes = await readBytesCapped(response, MAX_CHALLENGE_ECHO_BYTES);
92
+ if (bytes === null) {
93
+ return finish(false, response.status);
94
+ }
95
+ const echoed = new TextDecoder().decode(bytes).trim();
96
+ return finish(echoed === challenge, response.status);
97
+ }
98
+ /**
99
+ * Build the subscription-denial notification URL (WebSub §5.2): the subscriber's
100
+ * callback with `hub.mode=denied` and `hub.topic` appended, plus an optional
101
+ * `hub.reason`, preserving any query string the callback already carries.
102
+ */
103
+ export function buildDenialUrl(callback, params) {
104
+ const url = new URL(callback);
105
+ url.searchParams.append("hub.mode", "denied");
106
+ url.searchParams.append("hub.topic", params.topic);
107
+ if (params.reason !== undefined && params.reason !== "") {
108
+ url.searchParams.append("hub.reason", params.reason);
109
+ }
110
+ return url.toString();
111
+ }
112
+ /**
113
+ * Tell a subscriber its subscription was denied (WebSub §5.2): issue a
114
+ * best-effort `GET` to the callback with `hub.mode=denied`. Never throws — a
115
+ * denial that cannot be delivered (unreachable callback, SSRF block) is logged,
116
+ * not retried, since the subscription was never created either way. The GET goes
117
+ * through {@link safeFetch} so a hostile callback can't point the hub at its own
118
+ * network.
119
+ */
120
+ export async function notifyDenial(callback, topic, options) {
121
+ const doFetch = options?.fetch ?? ((input, init) => fetch(input, init));
122
+ const logger = options?.logger ?? noopLogger;
123
+ const metrics = options?.metrics ?? noopMetrics;
124
+ const url = buildDenialUrl(callback, { topic, reason: options?.reason });
125
+ // The denial *decision* stands regardless of whether the subscriber's callback
126
+ // can be reached, so the event is always emitted — silently swallowing it when
127
+ // the GET is blocked (e.g. an SSRF-blocked callback) would hide exactly the
128
+ // probing we want a signal for. `notified` records whether the callback
129
+ // actually accepted the GET, so a failed delivery is visible, not faked.
130
+ let notified = false;
131
+ try {
132
+ const result = await safeFetch(doFetch, url, { method: "GET" }, { logger, metrics });
133
+ notified = result.response.ok;
134
+ await result.response.body?.cancel().catch(() => undefined);
135
+ }
136
+ catch {
137
+ // Best-effort: the subscription row was never created, so a failed denial
138
+ // notification leaves no inconsistent state to repair.
139
+ }
140
+ const fields = {
141
+ callbackHost: hostFromUrl(callback),
142
+ topicHost: hostFromUrl(topic),
143
+ notified,
144
+ ...(options?.reason !== undefined ? { reason: options.reason } : {}),
145
+ };
146
+ logger.info(WebSubLogEvent.SubscriptionDenied, fields);
147
+ metrics.count(WebSubLogEvent.SubscriptionDenied, fields);
148
+ }
149
+ //# sourceMappingURL=verify.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verify.js","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"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"}