@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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xml.js","sourceRoot":"","sources":["../src/xml.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@dwk/microsub",
3
+ "version": "0.1.0-beta.0",
4
+ "description": "Microsub server: channel/feed subscriptions, server-side polling, and a normalised JF2 timeline. Consumes IndieAuth tokens.",
5
+ "keywords": [
6
+ "microsub",
7
+ "indieweb",
8
+ "jf2",
9
+ "feed-reader",
10
+ "cloudflare-workers"
11
+ ],
12
+ "type": "module",
13
+ "license": "ISC",
14
+ "author": "David W. Keith <me@dwk.io>",
15
+ "homepage": "https://github.com/davidwkeith/workers/tree/main/packages/microsub#readme",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/davidwkeith/workers.git",
19
+ "directory": "packages/microsub"
20
+ },
21
+ "sideEffects": false,
22
+ "main": "./dist/index.js",
23
+ "types": "./dist/index.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js"
28
+ }
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "src",
33
+ "!src/**/*.test.ts",
34
+ "!src/test-harness.ts"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "dependencies": {
40
+ "@dwk/dpop": "0.1.0-beta.0",
41
+ "@dwk/indieauth": "0.1.0-beta.0",
42
+ "@dwk/log": "0.1.0-beta.0"
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.build.json",
46
+ "typecheck": "tsc -p tsconfig.json",
47
+ "clean": "rm -rf dist"
48
+ }
49
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Request authorization: validate the IndieAuth access token, complete its DPoP
3
+ * proof-of-possession binding, honour revocation, and gate the action on the
4
+ * token's scope.
5
+ *
6
+ * Mirrors `@dwk/micropub`'s authorization exactly — Microsub is the read side of
7
+ * the same identity layer, so it accepts the same DPoP-bound HS256 access tokens
8
+ * `@dwk/indieauth` mints and applies the same single-owner subject check: a
9
+ * token minted by this issuer for a *different* `me` cannot read here. The DPoP
10
+ * proof binds the token to this exact request (RFC 9449), so a stolen bearer
11
+ * token alone is useless; revocation is checked against the strongly-consistent
12
+ * issued-token store, never a cache. Replay detection (for state-changing
13
+ * `POST`s) is the caller's, recorded in the strongly-consistent `MICROSUB_DB`.
14
+ *
15
+ * @packageDocumentation
16
+ */
17
+
18
+ import { DEFAULT_MAX_AGE_SECONDS, verifyDpopProof } from "@dwk/dpop";
19
+ import {
20
+ createIndieAuthStore,
21
+ verifyAccessToken,
22
+ type AccessTokenClaims,
23
+ type IndieAuthStoreEnv,
24
+ } from "@dwk/indieauth";
25
+
26
+ import type { ResolvedConfig } from "./config";
27
+ import { createDpopReplayStore } from "./replay";
28
+ import type { MicrosubStoreEnv } from "./store";
29
+
30
+ /** Bindings the authorization path needs. */
31
+ export interface AuthEnv extends IndieAuthStoreEnv, MicrosubStoreEnv {
32
+ /** HMAC key the IndieAuth token endpoint signed access tokens with. */
33
+ readonly TOKEN_SIGNING_KEY: string;
34
+ }
35
+
36
+ /** A failed authorization: an OAuth-style error to surface to the client. */
37
+ export interface AuthFailure {
38
+ readonly ok: false;
39
+ readonly error: string;
40
+ readonly description: string;
41
+ readonly status: number;
42
+ }
43
+
44
+ /** A successful authorization: the verified token claims. */
45
+ export interface AuthSuccess {
46
+ readonly ok: true;
47
+ readonly claims: AccessTokenClaims;
48
+ }
49
+
50
+ export type AuthResult = AuthSuccess | AuthFailure;
51
+
52
+ function failure(
53
+ error: string,
54
+ description: string,
55
+ status: number,
56
+ ): AuthFailure {
57
+ return { ok: false, error, description, status };
58
+ }
59
+
60
+ /**
61
+ * Extract the bearer token from the `Authorization` header. Both the RFC 9449
62
+ * `DPoP` scheme (which our tokens use) and the legacy `Bearer` scheme are
63
+ * accepted. Returns `null` when the header is absent or malformed.
64
+ */
65
+ export function tokenFromHeader(request: Request): string | null {
66
+ const header = request.headers.get("Authorization");
67
+ if (!header) return null;
68
+ const match = /^(DPoP|Bearer)\s+(.+)$/i.exec(header.trim());
69
+ return match ? (match[2] as string) : null;
70
+ }
71
+
72
+ /** Whether the granted scope string contains any of the acceptable scopes. */
73
+ export function hasScope(
74
+ scope: string,
75
+ acceptable: readonly string[],
76
+ ): boolean {
77
+ const granted = scope.split(/\s+/).filter(Boolean);
78
+ return acceptable.some((s) => granted.includes(s));
79
+ }
80
+
81
+ /**
82
+ * Authorize a request. Verifies the access token, completes the DPoP binding
83
+ * against this exact request (`htm`/`htu`/`ath`/`cnf.jkt`), checks revocation,
84
+ * records the proof for replay detection (when `recordReplay`), and — when
85
+ * `requiredScopes` is non-empty — enforces scope.
86
+ */
87
+ export async function authorize(
88
+ request: Request,
89
+ env: AuthEnv,
90
+ config: ResolvedConfig,
91
+ token: string | null,
92
+ requiredScopes: readonly string[],
93
+ recordReplay: boolean,
94
+ ): Promise<AuthResult> {
95
+ if (!token) {
96
+ return failure("unauthorized", "a bearer access token is required", 401);
97
+ }
98
+
99
+ const verified = await verifyAccessToken(token, env.TOKEN_SIGNING_KEY, {
100
+ issuer: config.tokenIssuer,
101
+ });
102
+ if (!verified.valid) {
103
+ return failure(
104
+ "invalid_token",
105
+ `access token rejected: ${verified.reason}`,
106
+ 401,
107
+ );
108
+ }
109
+ const claims = verified.claims;
110
+
111
+ // The token's subject (`sub`) is the canonical `me` it was minted for. A
112
+ // Microsub endpoint serves a single user, so reject any token whose subject is
113
+ // not this user — otherwise any token from the same issuer (for any `me`)
114
+ // could read this timeline. Both sides are canonicalized, so this is exact.
115
+ if (claims.sub !== config.me) {
116
+ return failure(
117
+ "invalid_token",
118
+ "access token subject is not the owner of this server",
119
+ 403,
120
+ );
121
+ }
122
+
123
+ // Complete the DPoP proof-of-possession binding for this request.
124
+ const proof = request.headers.get("DPoP");
125
+ if (!proof) {
126
+ return failure(
127
+ "invalid_request",
128
+ "a DPoP proof is required for token-bound requests",
129
+ 401,
130
+ );
131
+ }
132
+ const dpop = await verifyDpopProof({
133
+ proof,
134
+ htm: request.method,
135
+ htu: request.url,
136
+ accessToken: token,
137
+ expectedJkt: claims.cnf.jkt,
138
+ });
139
+ if (!dpop.valid) {
140
+ return failure(
141
+ "invalid_token",
142
+ `DPoP proof verification failed: ${dpop.reason}`,
143
+ 401,
144
+ );
145
+ }
146
+
147
+ // Replay: only for state-changing requests. A captured GET proof is far less
148
+ // valuable, and recording every read's proof would write on the read path.
149
+ if (recordReplay && config.checkDpopReplay && dpop.jti) {
150
+ const now = Math.floor(Date.now() / 1000);
151
+ const fresh = await createDpopReplayStore(env).recordProof(
152
+ dpop.jti,
153
+ now + 2 * DEFAULT_MAX_AGE_SECONDS,
154
+ now,
155
+ );
156
+ if (!fresh) {
157
+ return failure(
158
+ "invalid_token",
159
+ "DPoP proof has already been used (replay detected)",
160
+ 401,
161
+ );
162
+ }
163
+ }
164
+
165
+ // Revocation: staleness here is a security bug, so hit the strongly-consistent
166
+ // issued-token store rather than any cache.
167
+ if (config.checkRevocation) {
168
+ const store = createIndieAuthStore(env);
169
+ const now = Math.floor(Date.now() / 1000);
170
+ if (!(await store.isTokenActive(claims.jti, now))) {
171
+ return failure("invalid_token", "access token has been revoked", 401);
172
+ }
173
+ }
174
+
175
+ if (requiredScopes.length > 0 && !hasScope(claims.scope, requiredScopes)) {
176
+ return failure(
177
+ "insufficient_scope",
178
+ `this action requires one of the scopes: ${requiredScopes.join(", ")}`,
179
+ 403,
180
+ );
181
+ }
182
+
183
+ return { ok: true, claims };
184
+ }
package/src/config.ts ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * `@dwk/microsub` — injected configuration and the Cloudflare `Env` fragment.
3
+ *
4
+ * Per the composition contract a package never reads the global environment
5
+ * directly: all config (base URL, owner `me`, endpoint URL, page size, poll
6
+ * cadence) is passed into {@link createMicrosub}, so the server can be
7
+ * instantiated multiple times and unit-tested in isolation. The Cloudflare
8
+ * bindings it needs are declared as a TypeScript `Env` fragment that the
9
+ * composed Worker's `Env` is a superset of. See `spec/composition-contract.md`
10
+ * and `spec/packages/microsub.md`.
11
+ *
12
+ * @packageDocumentation
13
+ */
14
+
15
+ import { canonicalizeProfileUrl } from "@dwk/indieauth";
16
+ import { noopLogger, noopMetrics, type Logger, type Metrics } from "@dwk/log";
17
+ import type { D1Database, Queue } from "@cloudflare/workers-types";
18
+
19
+ import type { FetchLike } from "./fetch";
20
+ import type { MicrosubJob } from "./queue";
21
+
22
+ /**
23
+ * Cloudflare bindings required by the Microsub handler, poller, and queue
24
+ * consumer.
25
+ *
26
+ * The subscription + timeline store **MUST** be strongly consistent — D1
27
+ * (session consistency), never KV: a lost subscription or a dropped read-state
28
+ * flag is a correctness bug, not a safe-to-be-stale cache
29
+ * (`spec/non-functional-requirements.md`).
30
+ */
31
+ export interface MicrosubEnv {
32
+ /** D1 database for channels, follows, timeline items, and the poll cache. */
33
+ readonly MICROSUB_DB: D1Database;
34
+ /** Queue for feed-poll fan-out and retries. */
35
+ readonly MICROSUB_QUEUE: Queue<MicrosubJob>;
36
+ /** The `@dwk/indieauth` issued-token store (D1), consulted for revocation. */
37
+ readonly AUTH_DB: D1Database;
38
+ /** HMAC key the IndieAuth token endpoint signed access tokens with. */
39
+ readonly TOKEN_SIGNING_KEY: string;
40
+ }
41
+
42
+ /** Configuration passed to {@link createMicrosub} and its poller/consumer. */
43
+ export interface MicrosubConfig {
44
+ /** The identity root / base URL (e.g. `https://example.com`). */
45
+ readonly baseUrl: string;
46
+ /**
47
+ * The site owner's IndieAuth profile URL (`me`). A token only authorizes a
48
+ * request when its subject (`sub`) equals this, after canonicalization — so a
49
+ * token minted by the same issuer for a *different* `me` cannot read here.
50
+ * Required: a Microsub endpoint serves exactly one user.
51
+ */
52
+ readonly me: string;
53
+ /** Absolute Microsub endpoint URL. Defaults to `${origin}/microsub`. */
54
+ readonly microsubEndpoint?: string;
55
+ /**
56
+ * Expected access-token issuer (`iss`). Defaults to `baseUrl`, matching the
57
+ * `@dwk/indieauth` issuer default.
58
+ */
59
+ readonly tokenIssuer?: string;
60
+ /** Default timeline page size. Defaults to 20. */
61
+ readonly pageSize?: number;
62
+ /**
63
+ * Per-channel retention ceiling: when a poll pushes a channel above this, the
64
+ * oldest items are reaped. Defaults to 5000. Keeps a runaway feed from filling
65
+ * the D1 store unbounded.
66
+ */
67
+ readonly maxItemsPerChannel?: number;
68
+ /**
69
+ * Whether to check each token against the issued-token store (revocation).
70
+ * Defaults to `true` — staleness here is a security bug, so the check hits the
71
+ * strongly-consistent `AUTH_DB` rather than any cache.
72
+ */
73
+ readonly checkRevocation?: boolean;
74
+ /**
75
+ * Whether to reject replayed DPoP proofs on state-changing (`POST`) requests
76
+ * by tracking each accepted proof's `jti` in the strongly-consistent
77
+ * `MICROSUB_DB`. Defaults to `true`.
78
+ */
79
+ readonly checkDpopReplay?: boolean;
80
+ /** `fetch` implementation for discovery/polling/preview; defaults to global `fetch`. */
81
+ readonly fetch?: FetchLike;
82
+ /** Logger; defaults to a no-op (see `@dwk/log`). */
83
+ readonly logger?: Logger;
84
+ /** Metrics sink; defaults to a no-op (see `@dwk/log`). */
85
+ readonly metrics?: Metrics;
86
+ }
87
+
88
+ /** Fully resolved configuration with defaults applied and the path parsed. */
89
+ export interface ResolvedConfig {
90
+ readonly me: string;
91
+ readonly microsubEndpoint: string;
92
+ readonly microsubPath: string;
93
+ readonly tokenIssuer: string;
94
+ readonly pageSize: number;
95
+ readonly maxItemsPerChannel: number;
96
+ readonly checkRevocation: boolean;
97
+ readonly checkDpopReplay: boolean;
98
+ readonly fetch: FetchLike;
99
+ readonly logger: Logger;
100
+ readonly metrics: Metrics;
101
+ }
102
+
103
+ const DEFAULT_PAGE_SIZE = 20;
104
+ const DEFAULT_MAX_ITEMS = 5000;
105
+
106
+ function pathOf(absoluteUrl: string, label: string): string {
107
+ try {
108
+ return new URL(absoluteUrl).pathname;
109
+ } catch {
110
+ throw new Error(`@dwk/microsub: ${label} is not a valid URL`);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Resolve user config into a {@link ResolvedConfig}, applying defaults and
116
+ * pre-computing the endpoint pathname for request routing. Throws if `baseUrl`
117
+ * or `me` (or an explicitly supplied endpoint URL) is invalid.
118
+ */
119
+ export function resolveConfig(config: MicrosubConfig): ResolvedConfig {
120
+ let base: URL;
121
+ try {
122
+ base = new URL(config.baseUrl);
123
+ } catch {
124
+ throw new Error("@dwk/microsub: `baseUrl` is not a valid URL");
125
+ }
126
+
127
+ const me = canonicalizeProfileUrl(config.me);
128
+ if (me === null) {
129
+ throw new Error("@dwk/microsub: `me` is not a valid profile URL");
130
+ }
131
+
132
+ const microsubEndpoint = config.microsubEndpoint ?? `${base.origin}/microsub`;
133
+
134
+ const pageSize =
135
+ config.pageSize !== undefined && config.pageSize > 0
136
+ ? Math.floor(config.pageSize)
137
+ : DEFAULT_PAGE_SIZE;
138
+ const maxItemsPerChannel =
139
+ config.maxItemsPerChannel !== undefined && config.maxItemsPerChannel > 0
140
+ ? Math.floor(config.maxItemsPerChannel)
141
+ : DEFAULT_MAX_ITEMS;
142
+
143
+ return {
144
+ me,
145
+ microsubEndpoint,
146
+ microsubPath: pathOf(microsubEndpoint, "microsubEndpoint"),
147
+ tokenIssuer: config.tokenIssuer ?? config.baseUrl,
148
+ pageSize,
149
+ maxItemsPerChannel,
150
+ checkRevocation: config.checkRevocation ?? true,
151
+ checkDpopReplay: config.checkDpopReplay ?? true,
152
+ fetch: config.fetch ?? ((input, init) => fetch(input, init)),
153
+ logger: config.logger ?? noopLogger,
154
+ metrics: config.metrics ?? noopMetrics,
155
+ };
156
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * `@dwk/microsub` — the poll-queue consumer.
3
+ *
4
+ * The slow work the scheduled poller deferred runs here, off the request path,
5
+ * with the queue providing retries and backoff. For each `poll` job it:
6
+ *
7
+ * 1. fetches the feed conditionally (sending the cached `ETag` /
8
+ * `Last-Modified`); a `304` is acked with nothing to do;
9
+ * 2. parses the body to JF2 (Atom / RSS / JSON Feed / `h-feed`);
10
+ * 3. appends new entries — deduped by entry id — to every channel following
11
+ * that feed, reaping past the per-channel retention ceiling; and
12
+ * 4. records the returned validators for the next poll.
13
+ *
14
+ * A job whose fetch fails (unreachable / blocked / non-2xx) or whose store work
15
+ * throws is retried; everything else is acked. Feeds are assumed newest-first,
16
+ * so entries are inserted oldest-first and the newest gets the highest paging
17
+ * `seq`. See `spec/packages/microsub.md`.
18
+ *
19
+ * @packageDocumentation
20
+ */
21
+
22
+ import { hostFromUrl } from "@dwk/log";
23
+ import type { ExecutionContext, MessageBatch } from "@cloudflare/workers-types";
24
+
25
+ import {
26
+ resolveConfig,
27
+ type MicrosubConfig,
28
+ type MicrosubEnv,
29
+ type ResolvedConfig,
30
+ } from "./config";
31
+ import { fetchFeed } from "./discovery";
32
+ import { orderEntriesForInsert } from "./jf2";
33
+ import { MicrosubLogEvent } from "./log";
34
+ import type { MicrosubJob } from "./queue";
35
+ import { createMicrosubStore, type MicrosubStore } from "./store";
36
+
37
+ /** A Queue consumer for Microsub poll jobs. */
38
+ export type MicrosubQueueConsumer = (
39
+ batch: MessageBatch<MicrosubJob>,
40
+ env: MicrosubEnv,
41
+ ctx: ExecutionContext,
42
+ ) => Promise<void>;
43
+
44
+ /** Extra wiring for the consumer, primarily to inject a store/clock in tests. */
45
+ export interface ConsumerOptions {
46
+ /** Override the store; defaults to a D1 store over `MICROSUB_DB`. */
47
+ readonly store?: MicrosubStore;
48
+ /** Clock injection for deterministic tests; defaults to `Date.now`. */
49
+ readonly now?: () => number;
50
+ }
51
+
52
+ function emit(
53
+ config: ResolvedConfig,
54
+ event: string,
55
+ fields?: Record<string, unknown>,
56
+ ): void {
57
+ config.logger.info(event, fields);
58
+ config.metrics.count(event, fields);
59
+ }
60
+
61
+ /**
62
+ * Build the Queue consumer that polls feeds and appends to channel timelines.
63
+ * Fails loudly if no store is configured (neither `options.store` nor the
64
+ * `MICROSUB_DB` binding).
65
+ */
66
+ export function createMicrosubQueueConsumer(
67
+ config: MicrosubConfig,
68
+ options?: ConsumerOptions,
69
+ ): MicrosubQueueConsumer {
70
+ const resolved = resolveConfig(config);
71
+ const clock = options?.now ?? (() => Math.floor(Date.now() / 1000));
72
+
73
+ return async (batch, env, _ctx) => {
74
+ const store =
75
+ options?.store ??
76
+ (env.MICROSUB_DB
77
+ ? createMicrosubStore(env)
78
+ : (() => {
79
+ throw new Error(
80
+ "@dwk/microsub: missing required D1 binding `MICROSUB_DB`",
81
+ );
82
+ })());
83
+
84
+ for (const message of batch.messages) {
85
+ const job = message.body;
86
+ try {
87
+ const cache = await store.getFeedCache(job.feedUrl);
88
+ const fetched = await fetchFeed(
89
+ job.feedUrl,
90
+ {
91
+ fetch: resolved.fetch,
92
+ logger: resolved.logger,
93
+ metrics: resolved.metrics,
94
+ },
95
+ cache ?? undefined,
96
+ );
97
+ if (fetched === null) {
98
+ // Unreachable / blocked / non-2xx — retry the whole job later.
99
+ message.retry();
100
+ continue;
101
+ }
102
+
103
+ const now = clock();
104
+ await store.setFeedCache(
105
+ job.feedUrl,
106
+ fetched.etag,
107
+ fetched.lastModified,
108
+ now,
109
+ );
110
+
111
+ let added = 0;
112
+ if (!fetched.notModified && fetched.entries.length > 0) {
113
+ // Order oldest-first so the newest entry gets the highest paging `seq`.
114
+ const ordered = orderEntriesForInsert(fetched.entries);
115
+ const channels = await store.channelsForFeed(job.feedUrl);
116
+ for (const channel of channels) {
117
+ added += await store.insertItems(
118
+ channel,
119
+ job.feedUrl,
120
+ ordered,
121
+ now,
122
+ resolved.maxItemsPerChannel,
123
+ );
124
+ }
125
+ }
126
+ emit(resolved, MicrosubLogEvent.FeedPolled, {
127
+ added,
128
+ host: hostFromUrl(job.feedUrl),
129
+ });
130
+ message.ack();
131
+ } catch (err) {
132
+ emit(resolved, MicrosubLogEvent.PollRetry, {
133
+ error: err instanceof Error ? err.name : "unknown",
134
+ host: hostFromUrl(job.feedUrl),
135
+ });
136
+ message.retry();
137
+ }
138
+ }
139
+ };
140
+ }