@dwk/microsub 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 (83) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +92 -0
  3. package/dist/auth.d.ts +53 -0
  4. package/dist/auth.d.ts.map +1 -0
  5. package/dist/auth.js +102 -0
  6. package/dist/auth.js.map +1 -0
  7. package/dist/config.d.ts +102 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +64 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/consumer.d.ts +40 -0
  12. package/dist/consumer.d.ts.map +1 -0
  13. package/dist/consumer.js +87 -0
  14. package/dist/consumer.js.map +1 -0
  15. package/dist/discovery.d.ts +59 -0
  16. package/dist/discovery.d.ts.map +1 -0
  17. package/dist/discovery.js +190 -0
  18. package/dist/discovery.js.map +1 -0
  19. package/dist/fetch.d.ts +28 -0
  20. package/dist/fetch.d.ts.map +1 -0
  21. package/dist/fetch.js +72 -0
  22. package/dist/fetch.js.map +1 -0
  23. package/dist/handler.d.ts +24 -0
  24. package/dist/handler.d.ts.map +1 -0
  25. package/dist/handler.js +434 -0
  26. package/dist/handler.js.map +1 -0
  27. package/dist/hfeed.d.ts +25 -0
  28. package/dist/hfeed.d.ts.map +1 -0
  29. package/dist/hfeed.js +252 -0
  30. package/dist/hfeed.js.map +1 -0
  31. package/dist/index.d.ts +39 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +32 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/jf2.d.ts +69 -0
  36. package/dist/jf2.d.ts.map +1 -0
  37. package/dist/jf2.js +295 -0
  38. package/dist/jf2.js.map +1 -0
  39. package/dist/log.d.ts +44 -0
  40. package/dist/log.d.ts.map +1 -0
  41. package/dist/log.js +42 -0
  42. package/dist/log.js.map +1 -0
  43. package/dist/poll.d.ts +22 -0
  44. package/dist/poll.d.ts.map +1 -0
  45. package/dist/poll.js +39 -0
  46. package/dist/poll.js.map +1 -0
  47. package/dist/queue.d.ts +25 -0
  48. package/dist/queue.d.ts.map +1 -0
  49. package/dist/queue.js +13 -0
  50. package/dist/queue.js.map +1 -0
  51. package/dist/replay.d.ts +34 -0
  52. package/dist/replay.d.ts.map +1 -0
  53. package/dist/replay.js +49 -0
  54. package/dist/replay.js.map +1 -0
  55. package/dist/safe-fetch.d.ts +86 -0
  56. package/dist/safe-fetch.d.ts.map +1 -0
  57. package/dist/safe-fetch.js +311 -0
  58. package/dist/safe-fetch.js.map +1 -0
  59. package/dist/store.d.ts +131 -0
  60. package/dist/store.d.ts.map +1 -0
  61. package/dist/store.js +393 -0
  62. package/dist/store.js.map +1 -0
  63. package/dist/xml.d.ts +51 -0
  64. package/dist/xml.d.ts.map +1 -0
  65. package/dist/xml.js +196 -0
  66. package/dist/xml.js.map +1 -0
  67. package/package.json +49 -0
  68. package/src/auth.ts +184 -0
  69. package/src/config.ts +156 -0
  70. package/src/consumer.ts +140 -0
  71. package/src/discovery.ts +270 -0
  72. package/src/fetch.ts +82 -0
  73. package/src/handler.ts +594 -0
  74. package/src/hfeed.ts +287 -0
  75. package/src/index.ts +86 -0
  76. package/src/jf2.ts +394 -0
  77. package/src/log.ts +46 -0
  78. package/src/poll.ts +72 -0
  79. package/src/queue.ts +26 -0
  80. package/src/replay.ts +68 -0
  81. package/src/safe-fetch.ts +346 -0
  82. package/src/store.ts +644 -0
  83. package/src/xml.ts +229 -0
package/src/jf2.ts ADDED
@@ -0,0 +1,394 @@
1
+ /**
2
+ * `@dwk/microsub` — feed → JF2 normalisation.
3
+ *
4
+ * Microsub serves a single normalised timeline regardless of a source's wire
5
+ * format. This module turns the four feed formats a reader meets — JSON Feed,
6
+ * Atom, RSS 2.0, and `h-feed` microformats (the last parsed upstream in
7
+ * {@link ./hfeed}) — into [JF2](https://jf2.spec.indieweb.org/) `entry` objects.
8
+ *
9
+ * Everything here is **pure**: a feed body (and the format-sniffing on its
10
+ * content type) in, an array of JF2 entries out, each carrying a stable `_id`
11
+ * derived from the source's own identifier so the store can dedupe across polls.
12
+ * The XML formats go through the dependency-free reader in {@link ./xml}.
13
+ *
14
+ * @packageDocumentation
15
+ */
16
+
17
+ import {
18
+ child,
19
+ children,
20
+ childText,
21
+ parseXml,
22
+ text,
23
+ type XmlElement,
24
+ } from "./xml";
25
+
26
+ /** A JF2 author card. */
27
+ export interface Jf2Author {
28
+ readonly type: "card";
29
+ readonly name?: string;
30
+ readonly url?: string;
31
+ readonly photo?: string;
32
+ }
33
+
34
+ /** Structured JF2 content (`html` and/or its plain-text rendering). */
35
+ export interface Jf2Content {
36
+ readonly html?: string;
37
+ readonly text?: string;
38
+ }
39
+
40
+ /** A normalised JF2 timeline entry. */
41
+ export interface Jf2Entry {
42
+ readonly type: "entry";
43
+ /**
44
+ * Stable per-entry identifier, derived from the source's own id/guid/url. The
45
+ * store keys timeline rows on this so re-polling a feed does not duplicate
46
+ * entries.
47
+ */
48
+ readonly _id: string;
49
+ readonly url?: string;
50
+ readonly published?: string;
51
+ readonly name?: string;
52
+ readonly summary?: string;
53
+ readonly content?: Jf2Content;
54
+ readonly author?: Jf2Author;
55
+ readonly category?: readonly string[];
56
+ readonly photo?: readonly string[];
57
+ readonly "in-reply-to"?: string;
58
+ readonly "like-of"?: string;
59
+ /** Read flag, attached by the store on read (never by the parser). */
60
+ readonly _is_read?: boolean;
61
+ }
62
+
63
+ /** Resolve `href` against `base`, returning an absolute URL or the input. */
64
+ function absolute(href: string, base: string): string {
65
+ try {
66
+ return new URL(href, base).toString();
67
+ } catch {
68
+ return href;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Derive a stable entry id from the best available identifier. A feed-supplied
74
+ * id/guid/url is used verbatim; otherwise a short hash of the entry's content
75
+ * keeps re-polls from duplicating an entry that has no stable id of its own.
76
+ */
77
+ function entryId(candidate: string | undefined, fallback: string): string {
78
+ const basis =
79
+ candidate && candidate.trim() !== "" ? candidate.trim() : fallback;
80
+ return basis;
81
+ }
82
+
83
+ /** A small, stable non-cryptographic hash (FNV-1a) rendered as base36. */
84
+ function hash(input: string): string {
85
+ let h = 0x811c9dc5;
86
+ for (let i = 0; i < input.length; i++) {
87
+ h ^= input.charCodeAt(i);
88
+ h = Math.imul(h, 0x01000193);
89
+ }
90
+ return (h >>> 0).toString(36);
91
+ }
92
+
93
+ function defined<T extends object>(obj: T): T {
94
+ for (const key of Object.keys(obj) as (keyof T)[]) {
95
+ if (obj[key] === undefined) delete obj[key];
96
+ }
97
+ return obj;
98
+ }
99
+
100
+ // --- JSON Feed --------------------------------------------------------------
101
+
102
+ interface JsonFeedItem {
103
+ id?: unknown;
104
+ url?: unknown;
105
+ title?: unknown;
106
+ content_html?: unknown;
107
+ content_text?: unknown;
108
+ summary?: unknown;
109
+ date_published?: unknown;
110
+ tags?: unknown;
111
+ image?: unknown;
112
+ banner_image?: unknown;
113
+ authors?: unknown;
114
+ author?: unknown;
115
+ }
116
+
117
+ function str(value: unknown): string | undefined {
118
+ return typeof value === "string" && value !== "" ? value : undefined;
119
+ }
120
+
121
+ function jsonFeedAuthor(
122
+ item: JsonFeedItem,
123
+ feedAuthor?: Jf2Author,
124
+ ): Jf2Author | undefined {
125
+ const authors = Array.isArray(item.authors) ? item.authors : undefined;
126
+ const raw =
127
+ (authors?.[0] as Record<string, unknown> | undefined) ??
128
+ (item.author as Record<string, unknown> | undefined);
129
+ if (raw && typeof raw === "object") {
130
+ const card = defined({
131
+ type: "card" as const,
132
+ name: str(raw.name),
133
+ url: str(raw.url),
134
+ photo: str(raw.avatar),
135
+ });
136
+ if (card.name || card.url || card.photo) return card;
137
+ }
138
+ return feedAuthor;
139
+ }
140
+
141
+ function parseJsonFeed(body: string, baseUrl: string): Jf2Entry[] {
142
+ let doc: Record<string, unknown>;
143
+ try {
144
+ doc = JSON.parse(body) as Record<string, unknown>;
145
+ } catch {
146
+ return [];
147
+ }
148
+ const items = Array.isArray(doc.items) ? doc.items : [];
149
+ const feedAuthorRaw =
150
+ (Array.isArray(doc.authors)
151
+ ? (doc.authors[0] as Record<string, unknown>)
152
+ : undefined) ?? (doc.author as Record<string, unknown> | undefined);
153
+ const feedAuthor =
154
+ feedAuthorRaw && typeof feedAuthorRaw === "object"
155
+ ? defined({
156
+ type: "card" as const,
157
+ name: str(feedAuthorRaw.name),
158
+ url: str(feedAuthorRaw.url),
159
+ photo: str(feedAuthorRaw.avatar),
160
+ })
161
+ : undefined;
162
+
163
+ const entries: Jf2Entry[] = [];
164
+ for (const raw of items) {
165
+ if (!raw || typeof raw !== "object") continue;
166
+ const item = raw as JsonFeedItem;
167
+ const url = str(item.url);
168
+ const html = str(item.content_html);
169
+ const textContent = str(item.content_text);
170
+ const content =
171
+ html || textContent ? defined({ html, text: textContent }) : undefined;
172
+ const tags = Array.isArray(item.tags)
173
+ ? item.tags.filter((t): t is string => typeof t === "string")
174
+ : undefined;
175
+ const image = str(item.image) ?? str(item.banner_image);
176
+ const id = entryId(
177
+ str(item.id) ?? url,
178
+ hash(`${textContent ?? html ?? str(item.title) ?? ""}${url ?? ""}`),
179
+ );
180
+ entries.push(
181
+ defined({
182
+ type: "entry" as const,
183
+ _id: id,
184
+ url: url ? absolute(url, baseUrl) : undefined,
185
+ published: str(item.date_published),
186
+ name: str(item.title),
187
+ summary: str(item.summary),
188
+ content,
189
+ author: jsonFeedAuthor(item, feedAuthor),
190
+ category: tags && tags.length > 0 ? tags : undefined,
191
+ photo: image ? [absolute(image, baseUrl)] : undefined,
192
+ }) as Jf2Entry,
193
+ );
194
+ }
195
+ return entries;
196
+ }
197
+
198
+ // --- Atom -------------------------------------------------------------------
199
+
200
+ /** The `href` of the best Atom `<link>`: prefer `rel="alternate"`, else first. */
201
+ function atomLink(entry: XmlElement, baseUrl: string): string | undefined {
202
+ const links = children(entry, "link");
203
+ let fallback: string | undefined;
204
+ for (const link of links) {
205
+ const href = link.attrs.href;
206
+ if (!href) continue;
207
+ const rel = link.attrs.rel ?? "alternate";
208
+ if (rel === "alternate") return absolute(href, baseUrl);
209
+ fallback ??= absolute(href, baseUrl);
210
+ }
211
+ return fallback;
212
+ }
213
+
214
+ function atomAuthor(scope: XmlElement, baseUrl: string): Jf2Author | undefined {
215
+ const author = child(scope, "author");
216
+ if (author === null) return undefined;
217
+ const name = childText(author, "name");
218
+ const uri = childText(author, "uri");
219
+ const card = defined({
220
+ type: "card" as const,
221
+ name: name || undefined,
222
+ url: uri ? absolute(uri, baseUrl) : undefined,
223
+ });
224
+ return card.name || card.url ? card : undefined;
225
+ }
226
+
227
+ function parseAtom(feed: XmlElement, baseUrl: string): Jf2Entry[] {
228
+ const feedAuthor = atomAuthor(feed, baseUrl);
229
+ const entries: Jf2Entry[] = [];
230
+ for (const entry of children(feed, "entry")) {
231
+ const url = atomLink(entry, baseUrl);
232
+ const contentEl = child(entry, "content");
233
+ const summary = childText(entry, "summary");
234
+ const contentHtml = contentEl ? text(contentEl) : "";
235
+ const content = contentHtml
236
+ ? { html: contentHtml }
237
+ : summary
238
+ ? { html: summary }
239
+ : undefined;
240
+ const categories = children(entry, "category")
241
+ .map((c) => c.attrs.term ?? "")
242
+ .filter((t) => t !== "");
243
+ const id = entryId(
244
+ childText(entry, "id") || url,
245
+ hash(`${childText(entry, "title")}${url ?? ""}`),
246
+ );
247
+ entries.push(
248
+ defined({
249
+ type: "entry" as const,
250
+ _id: id,
251
+ url,
252
+ published:
253
+ childText(entry, "published") ||
254
+ childText(entry, "updated") ||
255
+ undefined,
256
+ name: childText(entry, "title") || undefined,
257
+ summary: summary || undefined,
258
+ content,
259
+ author: atomAuthor(entry, baseUrl) ?? feedAuthor,
260
+ category: categories.length > 0 ? categories : undefined,
261
+ }) as Jf2Entry,
262
+ );
263
+ }
264
+ return entries;
265
+ }
266
+
267
+ // --- RSS 2.0 / RDF ----------------------------------------------------------
268
+
269
+ function rssAuthor(item: XmlElement): Jf2Author | undefined {
270
+ const creator = childText(item, "dc:creator") || childText(item, "author");
271
+ if (creator === "") return undefined;
272
+ // RSS `<author>` is an email; `dc:creator` is a display name. Either way we
273
+ // surface the string as the card name (an email is the best we have).
274
+ return { type: "card", name: creator };
275
+ }
276
+
277
+ function parseRss(root: XmlElement, baseUrl: string): Jf2Entry[] {
278
+ // RSS 2.0 nests items under `<channel>`; RDF (RSS 1.0) lists them at the root.
279
+ const channel = child(root, "channel");
280
+ const scope = channel ?? root;
281
+ const items = [...children(scope, "item"), ...children(root, "item")];
282
+ const seen = new Set<XmlElement>();
283
+ const entries: Jf2Entry[] = [];
284
+ for (const item of items) {
285
+ if (seen.has(item)) continue;
286
+ seen.add(item);
287
+ const link = childText(item, "link");
288
+ const guidEl = child(item, "guid");
289
+ const guid = guidEl ? text(guidEl) : "";
290
+ const url = link
291
+ ? absolute(link, baseUrl)
292
+ : guid
293
+ ? absolute(guid, baseUrl)
294
+ : undefined;
295
+ const encoded = childText(item, "content:encoded");
296
+ const description = childText(item, "description");
297
+ const body = encoded || description;
298
+ const content = body ? { html: body } : undefined;
299
+ const categories = children(item, "category")
300
+ .map((c) => text(c))
301
+ .filter((t) => t !== "");
302
+ const id = entryId(
303
+ guid || link,
304
+ hash(`${childText(item, "title")}${url ?? ""}`),
305
+ );
306
+ entries.push(
307
+ defined({
308
+ type: "entry" as const,
309
+ _id: id,
310
+ url,
311
+ published:
312
+ childText(item, "pubdate") || childText(item, "dc:date") || undefined,
313
+ name: childText(item, "title") || undefined,
314
+ summary: encoded && description ? description : undefined,
315
+ content,
316
+ author: rssAuthor(item),
317
+ category: categories.length > 0 ? categories : undefined,
318
+ }) as Jf2Entry,
319
+ );
320
+ }
321
+ return entries;
322
+ }
323
+
324
+ // --- Ordering ---------------------------------------------------------------
325
+
326
+ /** Parse an entry's `published` to epoch milliseconds, or `null`. */
327
+ function publishedMs(entry: Jf2Entry): number | null {
328
+ if (!entry.published) return null;
329
+ const ms = Date.parse(entry.published);
330
+ return Number.isFinite(ms) ? ms : null;
331
+ }
332
+
333
+ /**
334
+ * Order a feed's entries oldest-first for insertion, so the newest entry is
335
+ * appended last and receives the highest paging `seq`.
336
+ *
337
+ * When the entries carry dates they are sorted chronologically (a feed's own
338
+ * order is not trusted). When none do — some `h-feed` pages — the feed is
339
+ * assumed newest-first and simply reversed. The sort is stable, so undated
340
+ * entries keep their relative position among dated ones.
341
+ */
342
+ export function orderEntriesForInsert(
343
+ entries: readonly Jf2Entry[],
344
+ ): Jf2Entry[] {
345
+ const anyDated = entries.some((entry) => publishedMs(entry) !== null);
346
+ if (!anyDated) return [...entries].reverse();
347
+ return entries
348
+ .map((entry, index) => ({ entry, index, ms: publishedMs(entry) }))
349
+ .sort((a, b) => {
350
+ // Undated entries sort as oldest (lowest seq); ties keep feed order.
351
+ const am = a.ms ?? Number.NEGATIVE_INFINITY;
352
+ const bm = b.ms ?? Number.NEGATIVE_INFINITY;
353
+ return am === bm ? a.index - b.index : am - bm;
354
+ })
355
+ .map((wrapped) => wrapped.entry);
356
+ }
357
+
358
+ // --- Dispatch ---------------------------------------------------------------
359
+
360
+ /** Whether a content type / body looks like JSON Feed. */
361
+ function looksLikeJson(contentType: string, body: string): boolean {
362
+ if (/\bjson\b/i.test(contentType)) return true;
363
+ const trimmed = body.trimStart();
364
+ return (
365
+ trimmed.startsWith("{") &&
366
+ /"version"\s*:\s*"https:\/\/jsonfeed\.org/.test(trimmed)
367
+ );
368
+ }
369
+
370
+ /**
371
+ * Parse a fetched feed body into JF2 entries, sniffing the format from the
372
+ * content type and the document root. Returns `[]` for an unrecognised or
373
+ * unparseable body (the caller treats an empty parse as "nothing new").
374
+ *
375
+ * `h-feed` HTML is **not** handled here — it needs the runtime's `HTMLRewriter`
376
+ * and lives in {@link ./hfeed}; this module is pure and runtime-free.
377
+ */
378
+ export function parseFeed(
379
+ body: string,
380
+ contentType: string,
381
+ baseUrl: string,
382
+ ): Jf2Entry[] {
383
+ if (looksLikeJson(contentType, body)) {
384
+ return parseJsonFeed(body, baseUrl);
385
+ }
386
+ const root = parseXml(body);
387
+ if (root === null) return [];
388
+ if (root.name === "feed") return parseAtom(root, baseUrl);
389
+ if (root.name === "rss" || root.name === "rdf:rdf")
390
+ return parseRss(root, baseUrl);
391
+ // Some JSON feeds are served with a generic content type and no version tag.
392
+ if (body.trimStart().startsWith("{")) return parseJsonFeed(body, baseUrl);
393
+ return [];
394
+ }
package/src/log.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `@dwk/microsub` — structured observability event taxonomy.
3
+ *
4
+ * Microsub consumes DPoP-bound access tokens, mutates the user's reading state,
5
+ * and fetches arbitrary URLs, so an authorization rejection, a validation
6
+ * rejection, or an SSRF block that is silently swallowed is an operational blind
7
+ * spot. Logging and metrics are opt-in via an injected {@link Logger} and
8
+ * {@link Metrics} (see `@dwk/log`) and **share this one vocabulary**: the same
9
+ * dotted event name is passed to the logger and the metrics sink so a log line
10
+ * and its counter line up. See `spec/observability.md`.
11
+ *
12
+ * Fields follow the redaction policy: only machine-readable reason codes, the
13
+ * action verb, scopes, counts, and a sanitized host — never tokens, entry
14
+ * bodies, or full URLs.
15
+ *
16
+ * @packageDocumentation
17
+ */
18
+
19
+ /** Stable event names emitted by `@dwk/microsub`. */
20
+ export const MicrosubLogEvent = {
21
+ /**
22
+ * A request failed authorization (missing/invalid token, subject mismatch,
23
+ * DPoP failure, revocation, or insufficient scope). Fields: `reason` (the
24
+ * OAuth/Microsub error code), `status`.
25
+ */
26
+ AuthRejected: "microsub.auth.rejected",
27
+ /**
28
+ * A well-formed-but-invalid request was rejected before any mutation (unknown
29
+ * action/method, missing `channel`/`url`, bad cursor). Field: `reason`.
30
+ */
31
+ RequestRejected: "microsub.request.rejected",
32
+ /** A channel/follow/timeline action completed. Fields: `action`, `method`. */
33
+ ActionCompleted: "microsub.action.completed",
34
+ /** A scheduled poll enqueued jobs for the followed feeds. Field: `feeds`. */
35
+ PollScheduled: "microsub.poll.scheduled",
36
+ /** A feed poll completed. Fields: `added`, `host`. */
37
+ FeedPolled: "microsub.feed.polled",
38
+ /** A queued poll job threw and is being retried. Fields: `error`, `host`. */
39
+ PollRetry: "microsub.poll.retry",
40
+ /** An outbound fetch was refused on SSRF grounds. Fields: `reason`, `host`. */
41
+ SsrfBlocked: "microsub.ssrf.blocked",
42
+ } as const;
43
+
44
+ /** Union of the event-name string literals in {@link MicrosubLogEvent}. */
45
+ export type MicrosubLogEvent =
46
+ (typeof MicrosubLogEvent)[keyof typeof MicrosubLogEvent];
package/src/poll.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `@dwk/microsub` — the scheduled poller.
3
+ *
4
+ * A Cron Trigger drives {@link createMicrosubPoller}: on each run it lists every
5
+ * distinct followed feed and enqueues one poll job per feed onto
6
+ * `MICROSUB_QUEUE`, so the slow fetch-and-parse work happens off this path, on
7
+ * the queue, with retries and backoff (see {@link ./consumer}). Polling never
8
+ * runs inline on the Microsub read path — the read path serves stored entries
9
+ * only (`spec/packages/microsub.md`).
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+
14
+ import type {
15
+ ExecutionContext,
16
+ ScheduledController,
17
+ } from "@cloudflare/workers-types";
18
+
19
+ import {
20
+ resolveConfig,
21
+ type MicrosubConfig,
22
+ type MicrosubEnv,
23
+ type ResolvedConfig,
24
+ } from "./config";
25
+ import { MicrosubLogEvent } from "./log";
26
+ import { createMicrosubStore } from "./store";
27
+
28
+ /** A `scheduled`-compatible Worker handler. */
29
+ export type MicrosubScheduledHandler = (
30
+ controller: ScheduledController,
31
+ env: MicrosubEnv,
32
+ ctx: ExecutionContext,
33
+ ) => Promise<void>;
34
+
35
+ function emit(
36
+ config: ResolvedConfig,
37
+ event: string,
38
+ fields?: Record<string, unknown>,
39
+ ): void {
40
+ config.logger.info(event, fields);
41
+ config.metrics.count(event, fields);
42
+ }
43
+
44
+ /**
45
+ * Build the scheduled poller. Fails loudly if `MICROSUB_DB` or `MICROSUB_QUEUE`
46
+ * is missing — no silent degradation (composition contract).
47
+ */
48
+ export function createMicrosubPoller(
49
+ config: MicrosubConfig,
50
+ ): MicrosubScheduledHandler {
51
+ const resolved = resolveConfig(config);
52
+ return async (_controller, env, _ctx) => {
53
+ if (!env.MICROSUB_DB) {
54
+ throw new Error(
55
+ "@dwk/microsub: missing required D1 binding `MICROSUB_DB`",
56
+ );
57
+ }
58
+ if (!env.MICROSUB_QUEUE) {
59
+ throw new Error(
60
+ "@dwk/microsub: missing required binding `MICROSUB_QUEUE`",
61
+ );
62
+ }
63
+ const store = createMicrosubStore(env);
64
+ const feeds = await store.distinctFeedUrls();
65
+ await Promise.all(
66
+ feeds.map((feedUrl) =>
67
+ env.MICROSUB_QUEUE.send({ kind: "poll", feedUrl }),
68
+ ),
69
+ );
70
+ emit(resolved, MicrosubLogEvent.PollScheduled, { feeds: feeds.length });
71
+ };
72
+ }
package/src/queue.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `@dwk/microsub` — queued job shapes.
3
+ *
4
+ * Polling — the slow, failure-prone work of fetching and parsing every followed
5
+ * feed — runs off the request path, on a queue with retries and backoff
6
+ * (`spec/packages/microsub.md`). The scheduled poller enqueues one job per
7
+ * distinct followed feed; the consumer fetches and appends. A single job kind
8
+ * flows through the queue today, discriminated by `kind` so the shape can grow.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+
13
+ /**
14
+ * Poll a single feed: fetch it (conditionally, via the stored `ETag` /
15
+ * `Last-Modified`), parse to JF2, and append new entries to every channel
16
+ * following it. The feed URL is the already-resolved subscription URL, not the
17
+ * page the user originally typed.
18
+ */
19
+ export interface PollJob {
20
+ readonly kind: "poll";
21
+ /** The resolved feed URL to fetch. */
22
+ readonly feedUrl: string;
23
+ }
24
+
25
+ /** A job on the Microsub poll queue. */
26
+ export type MicrosubJob = PollJob;
package/src/replay.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Strongly-consistent, short-TTL record of accepted DPoP proof `jti`s, so a
3
+ * captured proof cannot be replayed within its acceptance window to repeat a
4
+ * state-changing request. `@dwk/dpop` verifies a proof's freshness but, per
5
+ * RFC 9449, delegates replay detection to the caller via the returned `jti`;
6
+ * this is that caller-side record.
7
+ *
8
+ * The table lives in D1 (the strongly-consistent `MICROSUB_DB`) — never KV,
9
+ * where ~60 s of staleness would let a replayed proof slip through (see
10
+ * `spec/non-functional-requirements.md`). Rows are reaped once their proof can
11
+ * no longer be cryptographically accepted, so the table only ever tracks the
12
+ * live window.
13
+ *
14
+ * @packageDocumentation
15
+ */
16
+
17
+ import type { MicrosubStoreEnv } from "./store";
18
+
19
+ /** Storage interface over accepted DPoP proof `jti`s. */
20
+ export interface DpopReplayStore {
21
+ /** Create the schema if absent. Idempotent. */
22
+ init(): Promise<void>;
23
+ /**
24
+ * Atomically record an accepted proof `jti`. Returns `true` when the `jti`
25
+ * was unseen (and is now recorded), or `false` when it was already present —
26
+ * i.e. a replay. `expiresAt`/`now` are seconds since the epoch.
27
+ */
28
+ recordProof(jti: string, expiresAt: number, now: number): Promise<boolean>;
29
+ }
30
+
31
+ const SCHEMA = `CREATE TABLE IF NOT EXISTS microsub_dpop_proofs (
32
+ jti TEXT PRIMARY KEY,
33
+ expires_at INTEGER NOT NULL
34
+ )`;
35
+
36
+ /**
37
+ * Create the D1-backed {@link DpopReplayStore}. Fails loudly if the required
38
+ * `MICROSUB_DB` binding is missing — no silent degradation (composition
39
+ * contract); silently skipping replay detection would be a security bug.
40
+ */
41
+ export function createDpopReplayStore(env: MicrosubStoreEnv): DpopReplayStore {
42
+ if (!env.MICROSUB_DB) {
43
+ throw new Error("@dwk/microsub: missing required D1 binding `MICROSUB_DB`");
44
+ }
45
+ const db = env.MICROSUB_DB;
46
+
47
+ return {
48
+ async init() {
49
+ await db.prepare(SCHEMA).run();
50
+ },
51
+
52
+ async recordProof(jti, expiresAt, now) {
53
+ const results = await db.batch([
54
+ db.prepare(SCHEMA),
55
+ db
56
+ .prepare("DELETE FROM microsub_dpop_proofs WHERE expires_at <= ?")
57
+ .bind(now),
58
+ db
59
+ .prepare(
60
+ "INSERT OR IGNORE INTO microsub_dpop_proofs (jti, expires_at) VALUES (?, ?)",
61
+ )
62
+ .bind(jti, expiresAt),
63
+ ]);
64
+ // The INSERT is the third statement; `meta.changes` is 0 on a replay.
65
+ return (results[2]?.meta.changes ?? 0) > 0;
66
+ },
67
+ };
68
+ }