@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/fetch.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/websub` — injectable `fetch` type and a body-size cap.
|
|
3
|
+
*
|
|
4
|
+
* Intent verification, topic fetching, and content distribution all perform HTTP
|
|
5
|
+
* I/O. They accept a {@link FetchLike} so callers can inject a stub in tests (no
|
|
6
|
+
* network) and so the package never reaches for a global it didn't receive.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** A minimal, injectable `fetch` signature. */
|
|
12
|
+
export type FetchLike = (
|
|
13
|
+
input: string,
|
|
14
|
+
init?: RequestInit,
|
|
15
|
+
) => Promise<Response>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Default cap on a fetched body (4 MB). A challenge echo is tiny and a feed is
|
|
19
|
+
* modest; a larger body is almost certainly hostile or irrelevant, and buffering
|
|
20
|
+
* it would risk an OOM (the Worker memory limit is 128 MB). See
|
|
21
|
+
* `spec/non-functional-requirements.md`.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read a response body as a `Uint8Array`, refusing bodies larger than `maxBytes`.
|
|
27
|
+
*
|
|
28
|
+
* A declared `Content-Length` over the cap is rejected up front; the stream is
|
|
29
|
+
* then read incrementally and aborted the moment the cap is exceeded, so a
|
|
30
|
+
* missing or lying `Content-Length` cannot force the whole body into memory.
|
|
31
|
+
* Returns `null` when the body is too large or cannot be read.
|
|
32
|
+
*/
|
|
33
|
+
export async function readBytesCapped(
|
|
34
|
+
response: Response,
|
|
35
|
+
maxBytes = MAX_BODY_BYTES,
|
|
36
|
+
): Promise<Uint8Array | null> {
|
|
37
|
+
const declared = response.headers.get("content-length");
|
|
38
|
+
if (declared !== null) {
|
|
39
|
+
const length = Number.parseInt(declared, 10);
|
|
40
|
+
if (Number.isFinite(length) && length > maxBytes) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const body = response.body;
|
|
46
|
+
if (body === null) {
|
|
47
|
+
try {
|
|
48
|
+
const buffer = await response.arrayBuffer();
|
|
49
|
+
return buffer.byteLength > maxBytes ? null : new Uint8Array(buffer);
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const reader = body.getReader();
|
|
56
|
+
const chunks: Uint8Array[] = [];
|
|
57
|
+
let total = 0;
|
|
58
|
+
try {
|
|
59
|
+
for (;;) {
|
|
60
|
+
const { done, value } = await reader.read();
|
|
61
|
+
if (done) {
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
if (value !== undefined) {
|
|
65
|
+
total += value.byteLength;
|
|
66
|
+
if (total > maxBytes) {
|
|
67
|
+
await reader.cancel();
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
chunks.push(value);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const merged = new Uint8Array(total);
|
|
78
|
+
let offset = 0;
|
|
79
|
+
for (const chunk of chunks) {
|
|
80
|
+
merged.set(chunk, offset);
|
|
81
|
+
offset += chunk.byteLength;
|
|
82
|
+
}
|
|
83
|
+
return merged;
|
|
84
|
+
}
|
package/src/handler.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/websub` — the hub `fetch` handler and publish-notifier factory.
|
|
3
|
+
*
|
|
4
|
+
* `createWebSub` builds the WebSub hub endpoint: a single `POST`-only handler,
|
|
5
|
+
* mountable under any path prefix, that fields subscribe/unsubscribe requests and
|
|
6
|
+
* publish pings. Slow work (intent verification, content fan-out) is validated
|
|
7
|
+
* synchronously and pushed onto the queue, so the handler returns `202 Accepted`
|
|
8
|
+
* fast and the queue consumer (see {@link createWebSubQueueConsumer}) does the
|
|
9
|
+
* I/O with retries. `createPublishNotifier` is the in-process equivalent of a
|
|
10
|
+
* publish ping, for the Micropub write / Anglesite rebuild path. See
|
|
11
|
+
* `spec/packages/websub.md`.
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { hostFromUrl, type LogFields } from "@dwk/log";
|
|
17
|
+
import type { ExecutionContext } from "@cloudflare/workers-types";
|
|
18
|
+
import {
|
|
19
|
+
resolveConfig,
|
|
20
|
+
type ResolvedConfig,
|
|
21
|
+
type WebSubConfig,
|
|
22
|
+
type WebSubEnv,
|
|
23
|
+
} from "./config";
|
|
24
|
+
import { WebSubLogEvent } from "./log";
|
|
25
|
+
import { readHubParams, validatePublish, validateSubscribe } from "./validate";
|
|
26
|
+
|
|
27
|
+
/** A `fetch`-compatible Worker handler. */
|
|
28
|
+
export type WebSubHandler = (
|
|
29
|
+
request: Request,
|
|
30
|
+
env: WebSubEnv,
|
|
31
|
+
ctx: ExecutionContext,
|
|
32
|
+
) => Promise<Response>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* An in-process publish notifier: call it from the Micropub write path or an
|
|
36
|
+
* Anglesite rebuild hook to enqueue distribution of `topic` to its subscribers.
|
|
37
|
+
*/
|
|
38
|
+
export type PublishNotifier = (env: WebSubEnv, topic: string) => Promise<void>;
|
|
39
|
+
|
|
40
|
+
function textResponse(status: number, body: string): Response {
|
|
41
|
+
return new Response(body, {
|
|
42
|
+
status,
|
|
43
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function requireQueue(env: WebSubEnv): void {
|
|
48
|
+
if (env.WEBSUB_QUEUE === undefined) {
|
|
49
|
+
throw new Error("@dwk/websub: missing required binding WEBSUB_QUEUE.");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function emit(
|
|
54
|
+
config: ResolvedConfig,
|
|
55
|
+
level: "info" | "warn",
|
|
56
|
+
event: string,
|
|
57
|
+
fields?: LogFields,
|
|
58
|
+
): void {
|
|
59
|
+
config.logger[level](event, fields);
|
|
60
|
+
config.metrics.count(event, fields);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build the WebSub hub handler from configuration.
|
|
65
|
+
*
|
|
66
|
+
* Accepts a form-encoded `POST`. `hub.mode=subscribe|unsubscribe` is validated
|
|
67
|
+
* and the verification-of-intent job enqueued (`202`); `hub.mode=publish`
|
|
68
|
+
* enqueues a distribution job (`202`). Invalid requests get `400` with a stable
|
|
69
|
+
* error code; other methods get `405`. Fails loudly if `WEBSUB_QUEUE` is missing.
|
|
70
|
+
*/
|
|
71
|
+
export function createWebSub(config: WebSubConfig): WebSubHandler {
|
|
72
|
+
const resolved = resolveConfig(config);
|
|
73
|
+
return async (request, env, _ctx) => {
|
|
74
|
+
if (request.method !== "POST") {
|
|
75
|
+
return new Response("Method Not Allowed", {
|
|
76
|
+
status: 405,
|
|
77
|
+
headers: { allow: "POST" },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
requireQueue(env);
|
|
82
|
+
|
|
83
|
+
let form: FormData;
|
|
84
|
+
try {
|
|
85
|
+
form = await request.formData();
|
|
86
|
+
} catch {
|
|
87
|
+
return textResponse(400, "invalid_request");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Lift the form into a string-only URLSearchParams; a `File`-valued field
|
|
91
|
+
// (multipart upload) is not a valid `hub.*` parameter and is dropped.
|
|
92
|
+
const search = new URLSearchParams();
|
|
93
|
+
for (const [key, value] of form.entries()) {
|
|
94
|
+
if (typeof value === "string") {
|
|
95
|
+
search.append(key, value);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const params = readHubParams(search);
|
|
99
|
+
|
|
100
|
+
if (params.mode === "publish") {
|
|
101
|
+
const result = validatePublish(params, resolved);
|
|
102
|
+
if (!result.ok) {
|
|
103
|
+
emit(resolved, "warn", WebSubLogEvent.PublishRejected, {
|
|
104
|
+
reason: result.error,
|
|
105
|
+
});
|
|
106
|
+
return textResponse(400, result.error);
|
|
107
|
+
}
|
|
108
|
+
await env.WEBSUB_QUEUE.send({ kind: "distribute", topic: result.topic });
|
|
109
|
+
emit(resolved, "info", WebSubLogEvent.PublishAccepted, {
|
|
110
|
+
topicHost: hostFromUrl(result.topic),
|
|
111
|
+
});
|
|
112
|
+
return new Response(null, { status: 202 });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const result = validateSubscribe(params, resolved);
|
|
116
|
+
if (!result.ok) {
|
|
117
|
+
emit(resolved, "warn", WebSubLogEvent.RequestRejected, {
|
|
118
|
+
reason: result.error,
|
|
119
|
+
});
|
|
120
|
+
return textResponse(400, result.error);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await env.WEBSUB_QUEUE.send({
|
|
124
|
+
kind: "verify",
|
|
125
|
+
mode: result.mode,
|
|
126
|
+
callback: result.callback,
|
|
127
|
+
topic: result.topic,
|
|
128
|
+
leaseSeconds: result.leaseSeconds,
|
|
129
|
+
...(result.secret !== undefined ? { secret: result.secret } : {}),
|
|
130
|
+
});
|
|
131
|
+
emit(resolved, "info", WebSubLogEvent.RequestAccepted, {
|
|
132
|
+
mode: result.mode,
|
|
133
|
+
callbackHost: hostFromUrl(result.callback),
|
|
134
|
+
topicHost: hostFromUrl(result.topic),
|
|
135
|
+
});
|
|
136
|
+
return new Response(null, { status: 202 });
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Build an in-process publish notifier. The returned function enqueues a
|
|
142
|
+
* distribution job for `topic` (after checking it is a topic this hub serves),
|
|
143
|
+
* so a Micropub write or static rebuild can fan out a push without going through
|
|
144
|
+
* the HTTP publish endpoint.
|
|
145
|
+
*
|
|
146
|
+
* @throws when `topic` is not one this hub serves — a caller wiring this into its
|
|
147
|
+
* own write path should only ever publish its own feeds.
|
|
148
|
+
*/
|
|
149
|
+
export function createPublishNotifier(config: WebSubConfig): PublishNotifier {
|
|
150
|
+
const resolved = resolveConfig(config);
|
|
151
|
+
return async (env, topic) => {
|
|
152
|
+
requireQueue(env);
|
|
153
|
+
if (!resolved.isAllowedTopic(topic)) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`@dwk/websub: refusing to publish unsupported topic ${hostFromUrl(topic) ?? "<invalid>"}.`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
await env.WEBSUB_QUEUE.send({ kind: "distribute", topic });
|
|
159
|
+
emit(resolved, "info", WebSubLogEvent.PublishAccepted, {
|
|
160
|
+
topicHost: hostFromUrl(topic),
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/websub` — WebSub (W3C) hub.
|
|
3
|
+
*
|
|
4
|
+
* Endpoint package. The publish-side, real-time complement to `@dwk/webmention`:
|
|
5
|
+
* subscribers receive a push when the user's feed changes instead of polling. The
|
|
6
|
+
* hub fields `hub.mode=subscribe|unsubscribe` requests (validated synchronously,
|
|
7
|
+
* verified-of-intent and persisted asynchronously) and publish pings, keeps a
|
|
8
|
+
* strongly-consistent D1 subscription store with lease expiry, and on publish
|
|
9
|
+
* fans the topic's content out to every verified callback — signing the body with
|
|
10
|
+
* HMAC-SHA256 (`X-Hub-Signature`) when the subscriber registered a secret. The
|
|
11
|
+
* feeds themselves are static SSG artifacts (Anglesite's job, including the
|
|
12
|
+
* `Link rel="hub"`/`rel="self"` advertisement); this package is only the dynamic
|
|
13
|
+
* layer. Cloudflare specifics (D1, Queue) are confined here; validation, lease
|
|
14
|
+
* math, verification, and signing are pure and unit-test without a Workers
|
|
15
|
+
* runtime.
|
|
16
|
+
*
|
|
17
|
+
* @see spec/packages/websub.md
|
|
18
|
+
* @packageDocumentation
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
createWebSub,
|
|
23
|
+
createPublishNotifier,
|
|
24
|
+
type WebSubHandler,
|
|
25
|
+
type PublishNotifier,
|
|
26
|
+
} from "./handler";
|
|
27
|
+
export {
|
|
28
|
+
createWebSubQueueConsumer,
|
|
29
|
+
type WebSubQueueConsumer,
|
|
30
|
+
type ConsumerOptions,
|
|
31
|
+
} from "./consumer";
|
|
32
|
+
export {
|
|
33
|
+
resolveConfig,
|
|
34
|
+
normalizeTopic,
|
|
35
|
+
clampLease,
|
|
36
|
+
DEFAULT_MIN_LEASE_SECONDS,
|
|
37
|
+
DEFAULT_MAX_LEASE_SECONDS,
|
|
38
|
+
type WebSubConfig,
|
|
39
|
+
type WebSubEnv,
|
|
40
|
+
type ResolvedConfig,
|
|
41
|
+
} from "./config";
|
|
42
|
+
export {
|
|
43
|
+
createD1SubscriptionStore,
|
|
44
|
+
type SubscriptionStore,
|
|
45
|
+
type Subscription,
|
|
46
|
+
type SubscriptionUpsert,
|
|
47
|
+
type D1StoreOptions,
|
|
48
|
+
} from "./store";
|
|
49
|
+
export {
|
|
50
|
+
validateSubscribe,
|
|
51
|
+
validatePublish,
|
|
52
|
+
readHubParams,
|
|
53
|
+
MAX_SECRET_BYTES,
|
|
54
|
+
type SubscribeResult,
|
|
55
|
+
type SubscribeRequest,
|
|
56
|
+
type SubscribeError,
|
|
57
|
+
type PublishResult,
|
|
58
|
+
type PublishRequest,
|
|
59
|
+
type PublishError,
|
|
60
|
+
type RawHubParams,
|
|
61
|
+
} from "./validate";
|
|
62
|
+
export {
|
|
63
|
+
verifyIntent,
|
|
64
|
+
buildVerificationUrl,
|
|
65
|
+
generateChallenge,
|
|
66
|
+
notifyDenial,
|
|
67
|
+
buildDenialUrl,
|
|
68
|
+
type VerifyIntentOptions,
|
|
69
|
+
type VerifyIntentResult,
|
|
70
|
+
type NotifyDenialOptions,
|
|
71
|
+
} from "./verify";
|
|
72
|
+
export {
|
|
73
|
+
contentSignature,
|
|
74
|
+
buildLinkHeader,
|
|
75
|
+
fetchTopicContent,
|
|
76
|
+
deliverToSubscriber,
|
|
77
|
+
DEFAULT_SIGNATURE_ALGORITHM,
|
|
78
|
+
type SignatureAlgorithm,
|
|
79
|
+
type TopicContent,
|
|
80
|
+
type TopicFetchResult,
|
|
81
|
+
type DeliveryResult,
|
|
82
|
+
type DistributeOptions,
|
|
83
|
+
} from "./distribute";
|
|
84
|
+
export type { WebSubJob, VerifyJob, DistributeJob } from "./queue";
|
|
85
|
+
export type { FetchLike } from "./fetch";
|
|
86
|
+
export {
|
|
87
|
+
safeFetch,
|
|
88
|
+
assertPublicUrl,
|
|
89
|
+
isPrivateOrReservedHost,
|
|
90
|
+
SsrfError,
|
|
91
|
+
DEFAULT_MAX_REDIRECTS,
|
|
92
|
+
DEFAULT_TIMEOUT_MS,
|
|
93
|
+
type SafeFetchOptions,
|
|
94
|
+
type SafeFetchResult,
|
|
95
|
+
type SsrfReason,
|
|
96
|
+
} from "./safe-fetch";
|
|
97
|
+
export { WebSubLogEvent } from "./log";
|
|
98
|
+
export type { Logger, Metrics } from "@dwk/log";
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/websub` — structured observability event taxonomy.
|
|
3
|
+
*
|
|
4
|
+
* Like the rest of the cohort, this package's logging and metrics are opt-in via
|
|
5
|
+
* an injected {@link Logger} and {@link Metrics} (see `@dwk/log`) and **share one
|
|
6
|
+
* vocabulary**: the same dotted event name is passed to `logger.*(...)` and
|
|
7
|
+
* `metrics.count(...)` so a log line and its counter line up. Event names are
|
|
8
|
+
* stable, dotted, and queryable. Security-relevant events (a blocked SSRF
|
|
9
|
+
* attempt, a verification rejection) are first-class so being actively probed
|
|
10
|
+
* produces a distinct signal rather than silence. See `spec/observability.md`.
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Stable event names emitted by `@dwk/websub`. Fields logged alongside each
|
|
17
|
+
* event use `hostFromUrl` for any URL so an attacker-supplied path/query is
|
|
18
|
+
* never recorded; secrets and bodies are never logged.
|
|
19
|
+
*/
|
|
20
|
+
export const WebSubLogEvent = {
|
|
21
|
+
/**
|
|
22
|
+
* An outbound fetch was refused on SSRF grounds. Fields: `reason`
|
|
23
|
+
* (machine-readable cause), `host` (sanitized, when known).
|
|
24
|
+
*/
|
|
25
|
+
SsrfBlocked: "websub.ssrf.blocked",
|
|
26
|
+
/** A subscribe/unsubscribe request passed validation and was enqueued. Fields: `mode`, `callbackHost`, `topicHost`. */
|
|
27
|
+
RequestAccepted: "websub.request.accepted",
|
|
28
|
+
/** A subscribe/unsubscribe request was rejected at validation. Field: `reason`. */
|
|
29
|
+
RequestRejected: "websub.request.rejected",
|
|
30
|
+
/** A publish ping passed validation and distribution was enqueued. Field: `topicHost`. */
|
|
31
|
+
PublishAccepted: "websub.publish.accepted",
|
|
32
|
+
/** A publish ping was rejected at validation. Field: `reason`. */
|
|
33
|
+
PublishRejected: "websub.publish.rejected",
|
|
34
|
+
/** Intent verification of a callback completed. Fields: `mode`, `callbackHost`, `confirmed`, `status`. */
|
|
35
|
+
VerifyCompleted: "websub.verify.completed",
|
|
36
|
+
/** The verification GET to the callback failed/was blocked. Field: `error`. */
|
|
37
|
+
VerifyFetchFailed: "websub.verify.fetch_failed",
|
|
38
|
+
/** A subscription was activated (verified subscribe). Fields: `callbackHost`, `topicHost`, `leaseSeconds`. */
|
|
39
|
+
SubscriptionActivated: "websub.subscription.activated",
|
|
40
|
+
/** A subscription was removed (verified unsubscribe or pruned). Fields: `callbackHost`, `topicHost`, `reason`. */
|
|
41
|
+
SubscriptionRemoved: "websub.subscription.removed",
|
|
42
|
+
/** A subscription was denied (`hub.mode=denied`). Emitted whether or not the callback was reachable; `notified` records whether it accepted the GET. Fields: `callbackHost`, `topicHost`, `notified`, `reason`. */
|
|
43
|
+
SubscriptionDenied: "websub.subscription.denied",
|
|
44
|
+
/** A content-distribution delivery to one subscriber finished. Fields: `callbackHost`, `delivered`, `status`. */
|
|
45
|
+
DeliveryCompleted: "websub.delivery.completed",
|
|
46
|
+
/** A distribution job could not fetch the topic content. Fields: `topicHost`, `status`. */
|
|
47
|
+
TopicFetchFailed: "websub.topic.fetch_failed",
|
|
48
|
+
/** The topic declared no `Content-Type` and no fallback was configured, so distribution was refused rather than mislabeled (WebSub §7). Fields: `topicHost`, `status`. */
|
|
49
|
+
TopicContentTypeMissing: "websub.topic.content_type_missing",
|
|
50
|
+
/** A queue message threw and is being retried. Fields: `kind`, `error`. */
|
|
51
|
+
QueueRetry: "websub.queue.retry",
|
|
52
|
+
} as const;
|
|
53
|
+
|
|
54
|
+
/** Union of the event-name string literals in {@link WebSubLogEvent}. */
|
|
55
|
+
export type WebSubLogEvent =
|
|
56
|
+
(typeof WebSubLogEvent)[keyof typeof WebSubLogEvent];
|
package/src/queue.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/websub` — queued job shapes.
|
|
3
|
+
*
|
|
4
|
+
* The hub does its slow, failure-prone work — the verification-of-intent GET and
|
|
5
|
+
* content-distribution fan-out — off the request path, on a queue with retries
|
|
6
|
+
* and backoff (`spec/packages/websub.md`). Two job kinds flow through the one
|
|
7
|
+
* queue, discriminated by `kind`.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Verify a subscriber's intent: GET the callback with `hub.challenge` and,
|
|
14
|
+
* on a confirming 2xx echo, activate (subscribe) or remove (unsubscribe) the
|
|
15
|
+
* subscription. Carries everything needed to persist the subscription so the
|
|
16
|
+
* store write happens only after verification succeeds.
|
|
17
|
+
*/
|
|
18
|
+
export interface VerifyJob {
|
|
19
|
+
readonly kind: "verify";
|
|
20
|
+
readonly mode: "subscribe" | "unsubscribe";
|
|
21
|
+
readonly callback: string;
|
|
22
|
+
readonly topic: string;
|
|
23
|
+
/** Lease to grant on a confirmed subscribe (seconds); ignored for unsubscribe. */
|
|
24
|
+
readonly leaseSeconds: number;
|
|
25
|
+
/** Optional HMAC secret registered by the subscriber, stored on activation. */
|
|
26
|
+
readonly secret?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Distribute a topic's current content to every active subscriber: fetch the
|
|
31
|
+
* topic, then POST the body (signed per-subscriber when a secret is set) to each
|
|
32
|
+
* verified callback.
|
|
33
|
+
*/
|
|
34
|
+
export interface DistributeJob {
|
|
35
|
+
readonly kind: "distribute";
|
|
36
|
+
readonly topic: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A job on the WebSub queue: either intent verification or content distribution. */
|
|
40
|
+
export type WebSubJob = VerifyJob | DistributeJob;
|