@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.
- package/LICENSE +15 -0
- package/README.md +155 -0
- package/dist/config.d.ts +122 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +96 -0
- package/dist/config.js.map +1 -0
- package/dist/consumer.d.ts +39 -0
- package/dist/consumer.d.ts.map +1 -0
- package/dist/consumer.js +146 -0
- package/dist/consumer.js.map +1 -0
- package/dist/distribute.d.ts +109 -0
- package/dist/distribute.d.ts.map +1 -0
- package/dist/distribute.js +140 -0
- package/dist/distribute.js.map +1 -0
- package/dist/fetch.d.ts +28 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +73 -0
- package/dist/fetch.js.map +1 -0
- package/dist/handler.d.ts +43 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +127 -0
- package/dist/handler.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/log.d.ts +54 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +52 -0
- package/dist/log.js.map +1 -0
- package/dist/queue.d.ts +38 -0
- package/dist/queue.d.ts.map +1 -0
- package/dist/queue.js +12 -0
- package/dist/queue.js.map +1 -0
- package/dist/safe-fetch.d.ts +101 -0
- package/dist/safe-fetch.d.ts.map +1 -0
- package/dist/safe-fetch.js +354 -0
- package/dist/safe-fetch.js.map +1 -0
- package/dist/store.d.ts +61 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +110 -0
- package/dist/store.js.map +1 -0
- package/dist/validate.d.ts +67 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +106 -0
- package/dist/validate.js.map +1 -0
- package/dist/verify.d.ts +85 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +149 -0
- package/dist/verify.js.map +1 -0
- package/package.json +46 -0
- package/src/config.ts +199 -0
- package/src/consumer.ts +187 -0
- package/src/distribute.ts +257 -0
- package/src/fetch.ts +84 -0
- package/src/handler.ts +163 -0
- package/src/index.ts +98 -0
- package/src/log.ts +56 -0
- package/src/queue.ts +40 -0
- package/src/safe-fetch.ts +412 -0
- package/src/store.ts +190 -0
- package/src/validate.ts +179 -0
- package/src/verify.ts +229 -0
package/src/validate.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
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
|
+
|
|
13
|
+
import type { ResolvedConfig } from "./config";
|
|
14
|
+
|
|
15
|
+
/** WebSub §6.1.1: a `hub.secret` MUST be less than 200 bytes. */
|
|
16
|
+
export const MAX_SECRET_BYTES = 200;
|
|
17
|
+
|
|
18
|
+
/** Machine-readable rejection codes for a subscribe/unsubscribe request. */
|
|
19
|
+
export type SubscribeError =
|
|
20
|
+
| "invalid_mode"
|
|
21
|
+
| "callback_required"
|
|
22
|
+
| "callback_not_url"
|
|
23
|
+
| "topic_required"
|
|
24
|
+
| "topic_not_url"
|
|
25
|
+
| "topic_not_supported"
|
|
26
|
+
| "secret_too_long"
|
|
27
|
+
| "invalid_lease_seconds";
|
|
28
|
+
|
|
29
|
+
/** Machine-readable rejection codes for a publish request. */
|
|
30
|
+
export type PublishError =
|
|
31
|
+
| "url_required"
|
|
32
|
+
| "url_not_url"
|
|
33
|
+
| "topic_not_supported";
|
|
34
|
+
|
|
35
|
+
/** Parsed, validated subscribe/unsubscribe request. */
|
|
36
|
+
export interface SubscribeRequest {
|
|
37
|
+
readonly ok: true;
|
|
38
|
+
readonly mode: "subscribe" | "unsubscribe";
|
|
39
|
+
readonly callback: string;
|
|
40
|
+
readonly topic: string;
|
|
41
|
+
/** Requested lease, clamped into the hub's bounds; `undefined` left to the consumer. */
|
|
42
|
+
readonly leaseSeconds: number;
|
|
43
|
+
readonly secret?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Parsed, validated publish request. */
|
|
47
|
+
export interface PublishRequest {
|
|
48
|
+
readonly ok: true;
|
|
49
|
+
readonly mode: "publish";
|
|
50
|
+
readonly topic: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Result of validating a subscribe/unsubscribe request. */
|
|
54
|
+
export type SubscribeResult =
|
|
55
|
+
| SubscribeRequest
|
|
56
|
+
| { readonly ok: false; readonly error: SubscribeError };
|
|
57
|
+
|
|
58
|
+
/** Result of validating a publish request. */
|
|
59
|
+
export type PublishResult =
|
|
60
|
+
| PublishRequest
|
|
61
|
+
| { readonly ok: false; readonly error: PublishError };
|
|
62
|
+
|
|
63
|
+
function isHttpUrl(value: string): boolean {
|
|
64
|
+
let url: URL;
|
|
65
|
+
try {
|
|
66
|
+
url = new URL(value);
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function utf8Length(value: string): number {
|
|
74
|
+
return new TextEncoder().encode(value).length;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The raw `hub.*` fields lifted out of a form body. */
|
|
78
|
+
export interface RawHubParams {
|
|
79
|
+
readonly mode: string | null;
|
|
80
|
+
readonly callback: string | null;
|
|
81
|
+
readonly topic: string | null;
|
|
82
|
+
readonly url: string | null;
|
|
83
|
+
readonly leaseSeconds: string | null;
|
|
84
|
+
readonly secret: string | null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Lift the `hub.*` fields out of a parsed form body. */
|
|
88
|
+
export function readHubParams(form: URLSearchParams): RawHubParams {
|
|
89
|
+
return {
|
|
90
|
+
mode: form.get("hub.mode"),
|
|
91
|
+
callback: form.get("hub.callback"),
|
|
92
|
+
topic: form.get("hub.topic"),
|
|
93
|
+
url: form.get("hub.url"),
|
|
94
|
+
leaseSeconds: form.get("hub.lease_seconds"),
|
|
95
|
+
secret: form.get("hub.secret"),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Validate a subscribe/unsubscribe request against `config`. Clamps a supplied
|
|
101
|
+
* `hub.lease_seconds` into the hub's bounds and falls back to the default lease
|
|
102
|
+
* when it is absent.
|
|
103
|
+
*/
|
|
104
|
+
export function validateSubscribe(
|
|
105
|
+
params: RawHubParams,
|
|
106
|
+
config: ResolvedConfig,
|
|
107
|
+
): SubscribeResult {
|
|
108
|
+
if (params.mode !== "subscribe" && params.mode !== "unsubscribe") {
|
|
109
|
+
return { ok: false, error: "invalid_mode" };
|
|
110
|
+
}
|
|
111
|
+
if (params.callback === null || params.callback === "") {
|
|
112
|
+
return { ok: false, error: "callback_required" };
|
|
113
|
+
}
|
|
114
|
+
if (!isHttpUrl(params.callback)) {
|
|
115
|
+
return { ok: false, error: "callback_not_url" };
|
|
116
|
+
}
|
|
117
|
+
if (params.topic === null || params.topic === "") {
|
|
118
|
+
return { ok: false, error: "topic_required" };
|
|
119
|
+
}
|
|
120
|
+
if (!isHttpUrl(params.topic)) {
|
|
121
|
+
return { ok: false, error: "topic_not_url" };
|
|
122
|
+
}
|
|
123
|
+
if (!config.isAllowedTopic(params.topic)) {
|
|
124
|
+
return { ok: false, error: "topic_not_supported" };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let secret: string | undefined;
|
|
128
|
+
if (params.secret !== null && params.secret !== "") {
|
|
129
|
+
if (utf8Length(params.secret) >= MAX_SECRET_BYTES) {
|
|
130
|
+
return { ok: false, error: "secret_too_long" };
|
|
131
|
+
}
|
|
132
|
+
secret = params.secret;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let leaseSeconds = config.defaultLeaseSeconds;
|
|
136
|
+
if (params.leaseSeconds !== null && params.leaseSeconds !== "") {
|
|
137
|
+
const parsed = Number.parseInt(params.leaseSeconds, 10);
|
|
138
|
+
if (!Number.isFinite(parsed)) {
|
|
139
|
+
return { ok: false, error: "invalid_lease_seconds" };
|
|
140
|
+
}
|
|
141
|
+
// WebSub §5.1 treats `hub.lease_seconds` as a *request* the hub may clamp,
|
|
142
|
+
// not a hard constraint, so a `0` (or negative) request is clamped up to the
|
|
143
|
+
// hub minimum rather than rejected — the hub controls the granted lease.
|
|
144
|
+
leaseSeconds = Math.min(
|
|
145
|
+
Math.max(parsed, config.minLeaseSeconds),
|
|
146
|
+
config.maxLeaseSeconds,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
ok: true,
|
|
152
|
+
mode: params.mode,
|
|
153
|
+
callback: params.callback,
|
|
154
|
+
topic: params.topic,
|
|
155
|
+
leaseSeconds,
|
|
156
|
+
...(secret !== undefined ? { secret } : {}),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Validate a publish request. Accepts the topic in `hub.url` (WebSub §7) and
|
|
162
|
+
* falls back to `hub.topic` for compatibility with older publishers.
|
|
163
|
+
*/
|
|
164
|
+
export function validatePublish(
|
|
165
|
+
params: RawHubParams,
|
|
166
|
+
config: ResolvedConfig,
|
|
167
|
+
): PublishResult {
|
|
168
|
+
const topic = params.url ?? params.topic;
|
|
169
|
+
if (topic === null || topic === "") {
|
|
170
|
+
return { ok: false, error: "url_required" };
|
|
171
|
+
}
|
|
172
|
+
if (!isHttpUrl(topic)) {
|
|
173
|
+
return { ok: false, error: "url_not_url" };
|
|
174
|
+
}
|
|
175
|
+
if (!config.isAllowedTopic(topic)) {
|
|
176
|
+
return { ok: false, error: "topic_not_supported" };
|
|
177
|
+
}
|
|
178
|
+
return { ok: true, mode: "publish", topic };
|
|
179
|
+
}
|
package/src/verify.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
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
|
+
|
|
15
|
+
import {
|
|
16
|
+
hostFromUrl,
|
|
17
|
+
noopLogger,
|
|
18
|
+
noopMetrics,
|
|
19
|
+
type Logger,
|
|
20
|
+
type Metrics,
|
|
21
|
+
} from "@dwk/log";
|
|
22
|
+
import type { FetchLike } from "./fetch";
|
|
23
|
+
import { readBytesCapped } from "./fetch";
|
|
24
|
+
import { WebSubLogEvent } from "./log";
|
|
25
|
+
import { safeFetch } from "./safe-fetch";
|
|
26
|
+
|
|
27
|
+
/** Bytes of randomness in a generated challenge (hex-encoded → twice as many chars). */
|
|
28
|
+
const CHALLENGE_BYTES = 24;
|
|
29
|
+
/** A confirming challenge echo is tiny; refuse to buffer more than this. */
|
|
30
|
+
const MAX_CHALLENGE_ECHO_BYTES = 8 * 1024;
|
|
31
|
+
|
|
32
|
+
/** Inputs to {@link verifyIntent}. */
|
|
33
|
+
export interface VerifyIntentOptions {
|
|
34
|
+
readonly mode: "subscribe" | "unsubscribe";
|
|
35
|
+
/** Lease (seconds) advertised in the challenge; included only for subscribe. */
|
|
36
|
+
readonly leaseSeconds?: number;
|
|
37
|
+
/** `fetch` implementation; defaults to global `fetch`. */
|
|
38
|
+
readonly fetch?: FetchLike;
|
|
39
|
+
/** Challenge string to send; defaults to a fresh random value (override in tests). */
|
|
40
|
+
readonly challenge?: string;
|
|
41
|
+
readonly logger?: Logger;
|
|
42
|
+
readonly metrics?: Metrics;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Outcome of a verification-of-intent exchange. */
|
|
46
|
+
export interface VerifyIntentResult {
|
|
47
|
+
/** True when the callback echoed the exact challenge with a 2xx status. */
|
|
48
|
+
readonly confirmed: boolean;
|
|
49
|
+
/** The callback's HTTP status (`0` when the GET threw or was blocked). */
|
|
50
|
+
readonly status: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Generate a fresh, unguessable challenge value. */
|
|
54
|
+
export function generateChallenge(): string {
|
|
55
|
+
const bytes = crypto.getRandomValues(new Uint8Array(CHALLENGE_BYTES));
|
|
56
|
+
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build the verification callback URL, appending the `hub.*` query parameters to
|
|
61
|
+
* whatever the subscriber's callback already carries (WebSub §5.3). `hub.mode`
|
|
62
|
+
* and `hub.topic` echo the request; `hub.challenge` is the value the subscriber
|
|
63
|
+
* must return; `hub.lease_seconds` is included for a subscribe.
|
|
64
|
+
*/
|
|
65
|
+
export function buildVerificationUrl(
|
|
66
|
+
callback: string,
|
|
67
|
+
params: {
|
|
68
|
+
mode: "subscribe" | "unsubscribe";
|
|
69
|
+
topic: string;
|
|
70
|
+
challenge: string;
|
|
71
|
+
leaseSeconds?: number;
|
|
72
|
+
},
|
|
73
|
+
): string {
|
|
74
|
+
const url = new URL(callback);
|
|
75
|
+
url.searchParams.append("hub.mode", params.mode);
|
|
76
|
+
url.searchParams.append("hub.topic", params.topic);
|
|
77
|
+
url.searchParams.append("hub.challenge", params.challenge);
|
|
78
|
+
if (params.mode === "subscribe" && params.leaseSeconds !== undefined) {
|
|
79
|
+
url.searchParams.append("hub.lease_seconds", String(params.leaseSeconds));
|
|
80
|
+
}
|
|
81
|
+
return url.toString();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Verify a subscriber's intent for `callback` / `topic`.
|
|
86
|
+
*
|
|
87
|
+
* Issues the challenge GET through {@link safeFetch} and confirms the response is
|
|
88
|
+
* `2xx` with a body whose trimmed text equals the challenge. Any throw (network,
|
|
89
|
+
* timeout, SSRF block) is treated as "not confirmed" with status `0` — the
|
|
90
|
+
* caller does not distinguish a hostile callback from an unreachable one.
|
|
91
|
+
*/
|
|
92
|
+
export async function verifyIntent(
|
|
93
|
+
callback: string,
|
|
94
|
+
topic: string,
|
|
95
|
+
options: VerifyIntentOptions,
|
|
96
|
+
): Promise<VerifyIntentResult> {
|
|
97
|
+
const doFetch: FetchLike =
|
|
98
|
+
options.fetch ?? ((input, init) => fetch(input, init));
|
|
99
|
+
const logger = options.logger ?? noopLogger;
|
|
100
|
+
const metrics = options.metrics ?? noopMetrics;
|
|
101
|
+
const challenge = options.challenge ?? generateChallenge();
|
|
102
|
+
|
|
103
|
+
const url = buildVerificationUrl(callback, {
|
|
104
|
+
mode: options.mode,
|
|
105
|
+
topic,
|
|
106
|
+
challenge,
|
|
107
|
+
leaseSeconds: options.leaseSeconds,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const finish = (confirmed: boolean, status: number): VerifyIntentResult => {
|
|
111
|
+
const fields = {
|
|
112
|
+
mode: options.mode,
|
|
113
|
+
callbackHost: hostFromUrl(callback),
|
|
114
|
+
confirmed,
|
|
115
|
+
status,
|
|
116
|
+
};
|
|
117
|
+
logger.info(WebSubLogEvent.VerifyCompleted, fields);
|
|
118
|
+
metrics.count(WebSubLogEvent.VerifyCompleted, fields);
|
|
119
|
+
return { confirmed, status };
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
let response: Response;
|
|
123
|
+
try {
|
|
124
|
+
const result = await safeFetch(
|
|
125
|
+
doFetch,
|
|
126
|
+
url,
|
|
127
|
+
{ method: "GET" },
|
|
128
|
+
{ logger, metrics },
|
|
129
|
+
);
|
|
130
|
+
response = result.response;
|
|
131
|
+
} catch (err) {
|
|
132
|
+
const fields = {
|
|
133
|
+
callbackHost: hostFromUrl(callback),
|
|
134
|
+
error: err instanceof Error ? err.name : "unknown",
|
|
135
|
+
};
|
|
136
|
+
logger.warn(WebSubLogEvent.VerifyFetchFailed, fields);
|
|
137
|
+
metrics.count(WebSubLogEvent.VerifyFetchFailed, fields);
|
|
138
|
+
return finish(false, 0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
await response.body?.cancel().catch(() => undefined);
|
|
143
|
+
return finish(false, response.status);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const bytes = await readBytesCapped(response, MAX_CHALLENGE_ECHO_BYTES);
|
|
147
|
+
if (bytes === null) {
|
|
148
|
+
return finish(false, response.status);
|
|
149
|
+
}
|
|
150
|
+
const echoed = new TextDecoder().decode(bytes).trim();
|
|
151
|
+
return finish(echoed === challenge, response.status);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Inputs to {@link notifyDenial}. */
|
|
155
|
+
export interface NotifyDenialOptions {
|
|
156
|
+
/** Machine-readable cause echoed to the subscriber as `hub.reason` (optional). */
|
|
157
|
+
readonly reason?: string;
|
|
158
|
+
/** `fetch` implementation; defaults to global `fetch`. */
|
|
159
|
+
readonly fetch?: FetchLike;
|
|
160
|
+
readonly logger?: Logger;
|
|
161
|
+
readonly metrics?: Metrics;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Build the subscription-denial notification URL (WebSub §5.2): the subscriber's
|
|
166
|
+
* callback with `hub.mode=denied` and `hub.topic` appended, plus an optional
|
|
167
|
+
* `hub.reason`, preserving any query string the callback already carries.
|
|
168
|
+
*/
|
|
169
|
+
export function buildDenialUrl(
|
|
170
|
+
callback: string,
|
|
171
|
+
params: { topic: string; reason?: string },
|
|
172
|
+
): string {
|
|
173
|
+
const url = new URL(callback);
|
|
174
|
+
url.searchParams.append("hub.mode", "denied");
|
|
175
|
+
url.searchParams.append("hub.topic", params.topic);
|
|
176
|
+
if (params.reason !== undefined && params.reason !== "") {
|
|
177
|
+
url.searchParams.append("hub.reason", params.reason);
|
|
178
|
+
}
|
|
179
|
+
return url.toString();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Tell a subscriber its subscription was denied (WebSub §5.2): issue a
|
|
184
|
+
* best-effort `GET` to the callback with `hub.mode=denied`. Never throws — a
|
|
185
|
+
* denial that cannot be delivered (unreachable callback, SSRF block) is logged,
|
|
186
|
+
* not retried, since the subscription was never created either way. The GET goes
|
|
187
|
+
* through {@link safeFetch} so a hostile callback can't point the hub at its own
|
|
188
|
+
* network.
|
|
189
|
+
*/
|
|
190
|
+
export async function notifyDenial(
|
|
191
|
+
callback: string,
|
|
192
|
+
topic: string,
|
|
193
|
+
options?: NotifyDenialOptions,
|
|
194
|
+
): Promise<void> {
|
|
195
|
+
const doFetch: FetchLike =
|
|
196
|
+
options?.fetch ?? ((input, init) => fetch(input, init));
|
|
197
|
+
const logger = options?.logger ?? noopLogger;
|
|
198
|
+
const metrics = options?.metrics ?? noopMetrics;
|
|
199
|
+
|
|
200
|
+
const url = buildDenialUrl(callback, { topic, reason: options?.reason });
|
|
201
|
+
|
|
202
|
+
// The denial *decision* stands regardless of whether the subscriber's callback
|
|
203
|
+
// can be reached, so the event is always emitted — silently swallowing it when
|
|
204
|
+
// the GET is blocked (e.g. an SSRF-blocked callback) would hide exactly the
|
|
205
|
+
// probing we want a signal for. `notified` records whether the callback
|
|
206
|
+
// actually accepted the GET, so a failed delivery is visible, not faked.
|
|
207
|
+
let notified = false;
|
|
208
|
+
try {
|
|
209
|
+
const result = await safeFetch(
|
|
210
|
+
doFetch,
|
|
211
|
+
url,
|
|
212
|
+
{ method: "GET" },
|
|
213
|
+
{ logger, metrics },
|
|
214
|
+
);
|
|
215
|
+
notified = result.response.ok;
|
|
216
|
+
await result.response.body?.cancel().catch(() => undefined);
|
|
217
|
+
} catch {
|
|
218
|
+
// Best-effort: the subscription row was never created, so a failed denial
|
|
219
|
+
// notification leaves no inconsistent state to repair.
|
|
220
|
+
}
|
|
221
|
+
const fields = {
|
|
222
|
+
callbackHost: hostFromUrl(callback),
|
|
223
|
+
topicHost: hostFromUrl(topic),
|
|
224
|
+
notified,
|
|
225
|
+
...(options?.reason !== undefined ? { reason: options.reason } : {}),
|
|
226
|
+
};
|
|
227
|
+
logger.info(WebSubLogEvent.SubscriptionDenied, fields);
|
|
228
|
+
metrics.count(WebSubLogEvent.SubscriptionDenied, fields);
|
|
229
|
+
}
|