@dwk/activitypub 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 (48) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +135 -0
  3. package/dist/as2.d.ts +117 -0
  4. package/dist/as2.d.ts.map +1 -0
  5. package/dist/as2.js +174 -0
  6. package/dist/as2.js.map +1 -0
  7. package/dist/config.d.ts +148 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +142 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/delivery.d.ts +43 -0
  12. package/dist/delivery.d.ts.map +1 -0
  13. package/dist/delivery.js +131 -0
  14. package/dist/delivery.js.map +1 -0
  15. package/dist/handler.d.ts +21 -0
  16. package/dist/handler.d.ts.map +1 -0
  17. package/dist/handler.js +293 -0
  18. package/dist/handler.js.map +1 -0
  19. package/dist/index.d.ts +40 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +39 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/log.d.ts +57 -0
  24. package/dist/log.d.ts.map +1 -0
  25. package/dist/log.js +53 -0
  26. package/dist/log.js.map +1 -0
  27. package/dist/nodeinfo.d.ts +33 -0
  28. package/dist/nodeinfo.d.ts.map +1 -0
  29. package/dist/nodeinfo.js +61 -0
  30. package/dist/nodeinfo.js.map +1 -0
  31. package/dist/object.d.ts +21 -0
  32. package/dist/object.d.ts.map +1 -0
  33. package/dist/object.js +722 -0
  34. package/dist/object.js.map +1 -0
  35. package/dist/signature.d.ts +108 -0
  36. package/dist/signature.d.ts.map +1 -0
  37. package/dist/signature.js +234 -0
  38. package/dist/signature.js.map +1 -0
  39. package/package.json +50 -0
  40. package/src/as2.ts +257 -0
  41. package/src/config.ts +291 -0
  42. package/src/delivery.ts +155 -0
  43. package/src/handler.ts +370 -0
  44. package/src/index.ts +90 -0
  45. package/src/log.ts +62 -0
  46. package/src/nodeinfo.ts +91 -0
  47. package/src/object.ts +883 -0
  48. package/src/signature.ts +355 -0
package/src/as2.ts ADDED
@@ -0,0 +1,257 @@
1
+ /**
2
+ * ActivityStreams 2.0 vocabulary helpers for `@dwk/activitypub`.
3
+ *
4
+ * ActivityStreams 2.0 documents are JSON-LD, but the entire fediverse interops
5
+ * over the **compact** form with a fixed `@context`, so this module emits and
6
+ * parses that compact JSON shape directly rather than running a full JSON-LD
7
+ * processor. (Whether `@dwk/rdf`'s v1 JSON-LD subset covers the AS2 context is
8
+ * an open question — see `spec/open-questions.md` §4 — so v1 stays on the
9
+ * compact form Mastodon and friends actually speak.)
10
+ *
11
+ * Pure data: every function takes plain values and returns plain JSON objects,
12
+ * so the vocabulary is unit-testable without a Workers runtime or any binding.
13
+ */
14
+
15
+ /** The ActivityStreams 2.0 namespace IRI. */
16
+ export const AS2_NS = "https://www.w3.org/ns/activitystreams";
17
+
18
+ /** The W3C security vocabulary namespace (carries `publicKey`). */
19
+ export const SECURITY_NS = "https://w3id.org/security/v1";
20
+
21
+ /** The special "public" collection that marks an activity world-readable. */
22
+ export const PUBLIC_AUDIENCE = "https://www.w3.org/ns/activitystreams#Public";
23
+
24
+ /**
25
+ * The `@context` an actor document advertises: the AS2 base plus the security
26
+ * vocabulary (so `publicKey` resolves) and the handful of property aliases
27
+ * (`manuallyApprovesFollowers`, `discoverable`) the fediverse expects.
28
+ */
29
+ export const ACTOR_CONTEXT: readonly unknown[] = [
30
+ AS2_NS,
31
+ SECURITY_NS,
32
+ {
33
+ manuallyApprovesFollowers: "as:manuallyApprovesFollowers",
34
+ toot: "http://joinmastodon.org/ns#",
35
+ discoverable: "toot:discoverable",
36
+ },
37
+ ];
38
+
39
+ /** Media type for an ActivityStreams 2.0 / ActivityPub document. */
40
+ export const AS2_CONTENT_TYPE = "application/activity+json; charset=utf-8";
41
+
42
+ /**
43
+ * The JSON-LD profile variant of AS2. A strict client may content-negotiate for
44
+ * `application/ld+json` carrying the ActivityStreams profile rather than the
45
+ * `application/activity+json` alias the fediverse speaks; both name the same
46
+ * document (§3.2).
47
+ */
48
+ export const AS2_LD_CONTENT_TYPE =
49
+ 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8';
50
+
51
+ /**
52
+ * The media types a federation peer uses to request AS2. A `GET` whose `Accept`
53
+ * names any of these (or the matching `profile`) wants the activity JSON rather
54
+ * than an HTML profile page.
55
+ */
56
+ const AS2_ACCEPT_TOKENS = [
57
+ "application/activity+json",
58
+ "application/ld+json",
59
+ 'profile="https://www.w3.org/ns/activitystreams"',
60
+ ];
61
+
62
+ /** Whether an `Accept` header is asking for the ActivityStreams representation. */
63
+ export function wantsActivityJson(accept: string | null): boolean {
64
+ if (!accept) return false;
65
+ const lower = accept.toLowerCase();
66
+ return AS2_ACCEPT_TOKENS.some((token) => lower.includes(token));
67
+ }
68
+
69
+ /**
70
+ * Pick the AS2 media type to serve for a given `Accept` (§3.2 content
71
+ * negotiation). A peer that asks specifically for the JSON-LD profile variant
72
+ * (`application/ld+json`, without also naming `application/activity+json`) gets
73
+ * {@link AS2_LD_CONTENT_TYPE}; everyone else — plain browsers, and the
74
+ * `application/activity+json` alias the fediverse speaks — gets
75
+ * {@link AS2_CONTENT_TYPE}.
76
+ */
77
+ export function as2ContentType(accept: string | null): string {
78
+ if (accept && wantsActivityJson(accept)) {
79
+ const lower = accept.toLowerCase();
80
+ if (
81
+ lower.includes("application/ld+json") &&
82
+ !lower.includes("application/activity+json")
83
+ ) {
84
+ return AS2_LD_CONTENT_TYPE;
85
+ }
86
+ }
87
+ return AS2_CONTENT_TYPE;
88
+ }
89
+
90
+ /** A minimal JSON value type for AS2 documents. */
91
+ export type JsonValue =
92
+ | string
93
+ | number
94
+ | boolean
95
+ | null
96
+ | readonly JsonValue[]
97
+ | { readonly [key: string]: JsonValue };
98
+
99
+ /** A parsed ActivityStreams object/activity (compact JSON). */
100
+ export interface ActivityObject {
101
+ readonly id?: string;
102
+ readonly type?: string;
103
+ readonly actor?: JsonValue;
104
+ readonly object?: JsonValue;
105
+ readonly target?: JsonValue;
106
+ readonly to?: JsonValue;
107
+ readonly cc?: JsonValue;
108
+ readonly [key: string]: JsonValue | undefined;
109
+ }
110
+
111
+ /** The derived IRI set that identifies one actor and its collections. */
112
+ export interface ActorIris {
113
+ readonly id: string;
114
+ readonly inbox: string;
115
+ readonly outbox: string;
116
+ readonly followers: string;
117
+ readonly following: string;
118
+ readonly keyId: string;
119
+ }
120
+
121
+ /** The human-facing profile fields of an actor. */
122
+ export interface ActorProfile {
123
+ /** `preferredUsername` — the local part of the `acct:` handle. */
124
+ readonly username: string;
125
+ /** Display name; defaults to {@link username}. */
126
+ readonly name?: string;
127
+ /** Short bio rendered as `summary` (may contain HTML). */
128
+ readonly summary?: string;
129
+ /** Avatar image URL, surfaced as the actor `icon`. */
130
+ readonly icon?: string;
131
+ /** Whether follows require manual approval. Defaults to `false` (auto-accept). */
132
+ readonly manuallyApprovesFollowers?: boolean;
133
+ /** Whether the actor opts into discovery/search (Mastodon `toot:discoverable`). */
134
+ readonly discoverable?: boolean;
135
+ }
136
+
137
+ /** Optional extras woven into the actor document. */
138
+ export interface ActorDocumentOptions {
139
+ /**
140
+ * The instance-level shared inbox IRI, advertised under `endpoints` (§4.1 /
141
+ * §7.1.3) so large peers can batch-deliver to this actor. Omitted entirely
142
+ * when the deployment does not serve one.
143
+ */
144
+ readonly sharedInbox?: string;
145
+ }
146
+
147
+ /**
148
+ * Build the `Person` actor document served at the actor IRI. The public key is
149
+ * embedded inline (PEM) under the security vocabulary so verifiers can resolve
150
+ * `keyId` without a second fetch, exactly as Mastodon publishes it.
151
+ */
152
+ export function buildActorDocument(
153
+ iris: ActorIris,
154
+ profile: ActorProfile,
155
+ publicKeyPem: string,
156
+ options: ActorDocumentOptions = {},
157
+ ): Record<string, JsonValue> {
158
+ const doc: Record<string, JsonValue> = {
159
+ "@context": ACTOR_CONTEXT as JsonValue,
160
+ id: iris.id,
161
+ type: "Person",
162
+ preferredUsername: profile.username,
163
+ name: profile.name ?? profile.username,
164
+ inbox: iris.inbox,
165
+ outbox: iris.outbox,
166
+ followers: iris.followers,
167
+ following: iris.following,
168
+ manuallyApprovesFollowers: profile.manuallyApprovesFollowers ?? false,
169
+ discoverable: profile.discoverable ?? true,
170
+ publicKey: {
171
+ id: iris.keyId,
172
+ owner: iris.id,
173
+ publicKeyPem,
174
+ },
175
+ };
176
+ if (profile.summary !== undefined) doc.summary = profile.summary;
177
+ if (profile.icon !== undefined) {
178
+ doc.icon = { type: "Image", url: profile.icon };
179
+ }
180
+ if (options.sharedInbox !== undefined) {
181
+ doc.endpoints = { sharedInbox: options.sharedInbox };
182
+ }
183
+ return doc;
184
+ }
185
+
186
+ /**
187
+ * Build a paged `OrderedCollection` head document — the response to a bare
188
+ * collection `GET` (no `page` parameter). It advertises the total count and the
189
+ * `first`/`last` page links; the members live on the pages.
190
+ */
191
+ export function buildCollection(
192
+ id: string,
193
+ totalItems: number,
194
+ pageSize: number,
195
+ ): Record<string, JsonValue> {
196
+ const doc: Record<string, JsonValue> = {
197
+ "@context": AS2_NS,
198
+ id,
199
+ type: "OrderedCollection",
200
+ totalItems,
201
+ };
202
+ if (totalItems > 0) {
203
+ doc.first = `${id}?page=1`;
204
+ doc.last = `${id}?page=${Math.max(1, Math.ceil(totalItems / pageSize))}`;
205
+ }
206
+ return doc;
207
+ }
208
+
209
+ /**
210
+ * Build one `OrderedCollectionPage` of members. `next` is present only when a
211
+ * further page exists, so a crawler walks `first → next → …` until it stops.
212
+ */
213
+ export function buildCollectionPage(
214
+ collectionId: string,
215
+ page: number,
216
+ pageSize: number,
217
+ totalItems: number,
218
+ items: readonly JsonValue[],
219
+ ): Record<string, JsonValue> {
220
+ const doc: Record<string, JsonValue> = {
221
+ "@context": AS2_NS,
222
+ id: `${collectionId}?page=${page}`,
223
+ type: "OrderedCollectionPage",
224
+ partOf: collectionId,
225
+ totalItems,
226
+ orderedItems: items as JsonValue,
227
+ };
228
+ if (page * pageSize < totalItems) {
229
+ doc.next = `${collectionId}?page=${page + 1}`;
230
+ }
231
+ if (page > 1) doc.prev = `${collectionId}?page=${page - 1}`;
232
+ return doc;
233
+ }
234
+
235
+ /** Read the `id` of an activity's `object`, whether it is an IRI or nested object. */
236
+ export function objectId(value: JsonValue | undefined): string | undefined {
237
+ if (typeof value === "string") return value;
238
+ if (value && typeof value === "object" && !Array.isArray(value)) {
239
+ const id = (value as Record<string, JsonValue>).id;
240
+ if (typeof id === "string") return id;
241
+ }
242
+ return undefined;
243
+ }
244
+
245
+ /** Read the `type` of an activity's `object` when it is a nested object. */
246
+ export function objectType(value: JsonValue | undefined): string | undefined {
247
+ if (value && typeof value === "object" && !Array.isArray(value)) {
248
+ const type = (value as Record<string, JsonValue>).type;
249
+ if (typeof type === "string") return type;
250
+ }
251
+ return undefined;
252
+ }
253
+
254
+ /** Coerce an actor reference (IRI string or embedded object) to its IRI. */
255
+ export function actorIri(value: JsonValue | undefined): string | undefined {
256
+ return objectId(value);
257
+ }
package/src/config.ts ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Configuration, the declared Cloudflare `Env` fragment, and config resolution
3
+ * for `@dwk/activitypub`.
4
+ *
5
+ * Per the composition contract the package never reads the global environment
6
+ * directly: the actor profile, key material, and delivery policy are all passed
7
+ * into {@link createActivityPub}, so an actor can be instantiated multiple times
8
+ * and unit-tested in isolation. The only runtime coupling is the per-actor
9
+ * Durable Object namespace, and a missing binding fails loudly at startup.
10
+ */
11
+
12
+ import { noopLogger, noopMetrics, type Logger, type Metrics } from "@dwk/log";
13
+
14
+ import type { ActorIris, ActorProfile } from "./as2";
15
+ import type {
16
+ KeyResolver,
17
+ ResolvedKey,
18
+ VerifyResult,
19
+ InboxRequest,
20
+ } from "./signature";
21
+ import type { SoftwareInfo } from "./nodeinfo";
22
+ import type { ActivityPubObject } from "./object";
23
+
24
+ /** Cloudflare bindings required by the ActivityPub handler and Durable Object. */
25
+ export interface ActivityPubEnv {
26
+ /**
27
+ * Durable Object namespace for the per-actor class
28
+ * ({@link ActivityPubObject}). The single authoritative store for the inbox,
29
+ * outbox, follower/following collections, activity-`id` dedup, and the
30
+ * delivery queue.
31
+ */
32
+ readonly ACTOR: DurableObjectNamespace<ActivityPubObject>;
33
+ }
34
+
35
+ /** An override for inbound signature verification (see `@dwk/http-signatures`, #59). */
36
+ export type InboxVerifier = (
37
+ request: InboxRequest,
38
+ ) => Promise<VerifyResult> | VerifyResult;
39
+
40
+ /** Configuration passed to {@link createActivityPub}. */
41
+ export interface ActivityPubConfig {
42
+ /**
43
+ * The actor's identity root / base URL, e.g. `https://example.com`. The actor
44
+ * IRI and every collection IRI are derived from it. No trailing slash. Mount
45
+ * the package under a path prefix by including it here (e.g.
46
+ * `https://example.com/ap`).
47
+ */
48
+ readonly baseUrl: string;
49
+
50
+ /** The single actor this deployment serves (v1 is one actor per `baseUrl`). */
51
+ readonly actor: ActorProfile;
52
+
53
+ /**
54
+ * PEM-encoded SPKI **public** key, published inline in the actor document so
55
+ * peers can verify this actor's outbound signatures.
56
+ */
57
+ readonly publicKeyPem: string;
58
+
59
+ /**
60
+ * PEM-encoded PKCS#8 **private** key (a secret binding's value) used to sign
61
+ * outbound deliveries. Required to federate outbound (e.g. `Accept` a follow);
62
+ * omit it for a read-only actor that never delivers.
63
+ */
64
+ readonly privateKeyPem?: string;
65
+
66
+ /**
67
+ * Bearer token authorizing the owner-only publish endpoint
68
+ * (`POST <actor>/outbox`). When unset, that endpoint is disabled. This is the
69
+ * `@dwk/micropub` publish → `Create` fan-out seam; full C2S is out of scope
70
+ * for v1.
71
+ */
72
+ readonly publishToken?: string;
73
+
74
+ /**
75
+ * Whether to serve and advertise an instance-level **shared inbox** at
76
+ * `${baseUrl}/inbox` (ActivityPub §4.1 / §7.1.3), letting large peers
77
+ * batch-deliver to this actor. Defaults to `true`; set `false` to publish no
78
+ * `endpoints.sharedInbox` and serve no shared-inbox route.
79
+ */
80
+ readonly sharedInbox?: boolean;
81
+
82
+ /** Members served per `OrderedCollection` page. Defaults to 50. */
83
+ readonly pageSize?: number;
84
+
85
+ /** Max delivery attempts before a queued activity is dropped. Defaults to 8. */
86
+ readonly deliveryMaxAttempts?: number;
87
+
88
+ /** Base backoff (ms) for delivery retries (doubled per attempt). Defaults to 60_000. */
89
+ readonly deliveryBaseDelayMs?: number;
90
+
91
+ /** Accepted clock skew (seconds) on inbound signed `Date` headers. Defaults to 300. */
92
+ readonly clockSkewSeconds?: number;
93
+
94
+ /** Software identity for the NodeInfo document. Defaults to this package. */
95
+ readonly software?: SoftwareInfo;
96
+
97
+ /**
98
+ * Resolve a `keyId` to its owner + PEM key for inbound verification. Defaults
99
+ * to fetching the `keyId` (an actor or key IRI) as AS2 and reading
100
+ * `publicKey`. Supply your own to add caching or a key store.
101
+ */
102
+ readonly keyResolver?: KeyResolver;
103
+
104
+ /**
105
+ * Override inbound signature verification entirely (e.g. to plug in
106
+ * `@dwk/http-signatures`). When set, the built-in draft-cavage verifier and
107
+ * {@link keyResolver} are bypassed.
108
+ */
109
+ readonly verifyInboxSignature?: InboxVerifier;
110
+
111
+ /** `fetch` used for key resolution and outbound delivery. Defaults to global `fetch`. */
112
+ readonly fetch?: typeof fetch;
113
+
114
+ /** Injectable clock (epoch ms) for deterministic tests. Defaults to `Date.now`. */
115
+ readonly now?: () => number;
116
+
117
+ /** Structured-logging seam; defaults to a no-op. */
118
+ readonly logger?: Logger;
119
+ /** Metrics seam; defaults to a no-op. */
120
+ readonly metrics?: Metrics;
121
+ }
122
+
123
+ /** Fully-resolved configuration with defaults applied and IRIs derived. */
124
+ export interface ResolvedConfig {
125
+ readonly baseUrl: string;
126
+ readonly actor: ActorProfile;
127
+ readonly iris: ActorIris;
128
+ /** Instance-level shared inbox IRI, or `undefined` when not served. */
129
+ readonly sharedInbox?: string;
130
+ readonly publicKeyPem: string;
131
+ readonly privateKeyPem?: string;
132
+ readonly publishToken?: string;
133
+ readonly pageSize: number;
134
+ readonly deliveryMaxAttempts: number;
135
+ readonly deliveryBaseDelayMs: number;
136
+ readonly clockSkewSeconds: number;
137
+ readonly software: SoftwareInfo;
138
+ readonly keyResolver: KeyResolver;
139
+ readonly verifyInboxSignature?: InboxVerifier;
140
+ readonly fetch: typeof fetch;
141
+ readonly now: () => number;
142
+ readonly logger: Logger;
143
+ readonly metrics: Metrics;
144
+ }
145
+
146
+ /**
147
+ * Internal headers the trusted front door uses to hand verified facts and the
148
+ * config subset the DO needs (including signing key material, which never leaves
149
+ * Cloudflare's network) to the Durable Object.
150
+ */
151
+ export const INTERNAL_HEADERS = {
152
+ /** The verified actor IRI that signed an inbound `POST /inbox` (absent ⇒ unverified). */
153
+ signedActor: "x-ap-signed-actor",
154
+ /** JSON config subset the DO needs (IRIs, delivery policy, signing key). */
155
+ config: "x-ap-config",
156
+ /** Marks an owner-authorized publish request (`POST <actor>/outbox`). */
157
+ publish: "x-ap-publish",
158
+ } as const;
159
+
160
+ /** The config subset the front door forwards to the DO via {@link INTERNAL_HEADERS.config}. */
161
+ export interface ForwardedConfig {
162
+ readonly iris: ActorIris;
163
+ readonly actorName: string;
164
+ /** Shared inbox IRI the DO should also accept inbound `POST`s on, if served. */
165
+ readonly sharedInbox?: string;
166
+ readonly manuallyApprovesFollowers: boolean;
167
+ readonly pageSize: number;
168
+ readonly deliveryMaxAttempts: number;
169
+ readonly deliveryBaseDelayMs: number;
170
+ readonly keyId: string;
171
+ /** Private key (PKCS#8 PEM) so the DO can sign deliveries from its alarm. */
172
+ readonly privateKeyPem?: string;
173
+ }
174
+
175
+ const DEFAULT_PAGE_SIZE = 50;
176
+ const DEFAULT_MAX_ATTEMPTS = 8;
177
+ const DEFAULT_BASE_DELAY_MS = 60_000;
178
+ const DEFAULT_CLOCK_SKEW_SECONDS = 300;
179
+
180
+ const DEFAULT_SOFTWARE: SoftwareInfo = {
181
+ name: "dwk-activitypub",
182
+ version: "0.0.0",
183
+ };
184
+
185
+ /** Strip a trailing slash so path joins are unambiguous. */
186
+ function normalizeBaseUrl(baseUrl: string): string {
187
+ return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
188
+ }
189
+
190
+ /** Derive the actor + collection IRIs from a base URL and username. */
191
+ export function deriveIris(baseUrl: string, username: string): ActorIris {
192
+ const id = `${baseUrl}/users/${encodeURIComponent(username)}`;
193
+ return {
194
+ id,
195
+ inbox: `${id}/inbox`,
196
+ outbox: `${id}/outbox`,
197
+ followers: `${id}/followers`,
198
+ following: `${id}/following`,
199
+ keyId: `${id}#main-key`,
200
+ };
201
+ }
202
+
203
+ /**
204
+ * The default key resolver: fetch the `keyId` (an actor or fragment IRI) as AS2
205
+ * and read its embedded `publicKey`. Returns `null` when the document cannot be
206
+ * fetched or carries no usable key, which the verifier maps to a rejection.
207
+ */
208
+ function defaultKeyResolver(fetchImpl: typeof fetch): KeyResolver {
209
+ return async (keyId: string): Promise<ResolvedKey | null> => {
210
+ // The key IRI is the actor (or key) document URL with its fragment
211
+ // stripped. Parse it as a URL so the fragment is removed per the URL spec
212
+ // (rather than by string surgery) and an unparseable `keyId` is rejected
213
+ // before it reaches `fetch`.
214
+ let actorUrl: string;
215
+ try {
216
+ const url = new URL(keyId);
217
+ // Only ever dereference a remote actor/key document over HTTP(S); reject
218
+ // other schemes (`file:`, `data:`, …) outright so a crafted `keyId`
219
+ // cannot redirect the fetch at a non-network resource.
220
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
221
+ url.hash = "";
222
+ actorUrl = url.href;
223
+ } catch {
224
+ return null;
225
+ }
226
+ let response: Response;
227
+ try {
228
+ response = await fetchImpl(actorUrl, {
229
+ headers: { accept: "application/activity+json" },
230
+ // Bound key resolution so a slow remote cannot stall inbox verification.
231
+ signal: AbortSignal.timeout(10_000),
232
+ });
233
+ } catch {
234
+ return null;
235
+ }
236
+ if (!response.ok) return null;
237
+ let doc: unknown;
238
+ try {
239
+ doc = await response.json();
240
+ } catch {
241
+ return null;
242
+ }
243
+ if (!doc || typeof doc !== "object") return null;
244
+ const publicKey = (doc as Record<string, unknown>).publicKey;
245
+ if (!publicKey || typeof publicKey !== "object") return null;
246
+ const pem = (publicKey as Record<string, unknown>).publicKeyPem;
247
+ const owner =
248
+ (publicKey as Record<string, unknown>).owner ??
249
+ (doc as Record<string, unknown>).id;
250
+ if (typeof pem !== "string" || typeof owner !== "string") return null;
251
+ return { owner, publicKeyPem: pem };
252
+ };
253
+ }
254
+
255
+ /** Apply defaults and derive IRIs from raw {@link ActivityPubConfig}. */
256
+ export function resolveConfig(config: ActivityPubConfig): ResolvedConfig {
257
+ if (!config.baseUrl) {
258
+ throw new Error("@dwk/activitypub: `baseUrl` is required");
259
+ }
260
+ if (!config.actor || !config.actor.username) {
261
+ throw new Error("@dwk/activitypub: `actor.username` is required");
262
+ }
263
+ if (!config.publicKeyPem) {
264
+ throw new Error("@dwk/activitypub: `publicKeyPem` is required");
265
+ }
266
+ const baseUrl = normalizeBaseUrl(config.baseUrl);
267
+ const fetchImpl = config.fetch ?? fetch;
268
+ const sharedInbox =
269
+ (config.sharedInbox ?? true) ? `${baseUrl}/inbox` : undefined;
270
+
271
+ return {
272
+ baseUrl,
273
+ actor: config.actor,
274
+ iris: deriveIris(baseUrl, config.actor.username),
275
+ sharedInbox,
276
+ publicKeyPem: config.publicKeyPem,
277
+ privateKeyPem: config.privateKeyPem,
278
+ publishToken: config.publishToken,
279
+ pageSize: config.pageSize ?? DEFAULT_PAGE_SIZE,
280
+ deliveryMaxAttempts: config.deliveryMaxAttempts ?? DEFAULT_MAX_ATTEMPTS,
281
+ deliveryBaseDelayMs: config.deliveryBaseDelayMs ?? DEFAULT_BASE_DELAY_MS,
282
+ clockSkewSeconds: config.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS,
283
+ software: config.software ?? DEFAULT_SOFTWARE,
284
+ keyResolver: config.keyResolver ?? defaultKeyResolver(fetchImpl),
285
+ verifyInboxSignature: config.verifyInboxSignature,
286
+ fetch: fetchImpl,
287
+ now: config.now ?? (() => Date.now()),
288
+ logger: config.logger ?? noopLogger,
289
+ metrics: config.metrics ?? noopMetrics,
290
+ };
291
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Outbound delivery for `@dwk/activitypub`: sign an activity and `POST` it to a
3
+ * remote inbox.
4
+ *
5
+ * Delivery targets are attacker-influenced URLs (a follower's advertised inbox),
6
+ * so every target passes a syntactic SSRF guard before any request leaves —
7
+ * rejecting non-HTTPS schemes and private / loopback / link-local hosts so a
8
+ * malicious actor cannot point a delivery at the Worker's own network. (DNS
9
+ * rebinding is out of scope: the Workers runtime does not expose name
10
+ * resolution to user code; see `@dwk/webmention`'s safe-fetch for the same
11
+ * limitation.) The forthcoming `@dwk/http-signatures` package owns the signing
12
+ * primitive; this module wires it to the network.
13
+ */
14
+
15
+ import { signRequest, type SignerKey } from "./signature";
16
+
17
+ /** Machine-readable cause of a blocked delivery target. */
18
+ export type BlockedReason =
19
+ | "invalid_url"
20
+ | "disallowed_scheme"
21
+ | "blocked_host";
22
+
23
+ /** Raised when a delivery target is refused on SSRF grounds. */
24
+ export class DeliveryBlockedError extends Error {
25
+ readonly reason: BlockedReason;
26
+ constructor(reason: BlockedReason) {
27
+ super(`delivery target blocked: ${reason}`);
28
+ this.name = "DeliveryBlockedError";
29
+ this.reason = reason;
30
+ }
31
+ }
32
+
33
+ /** Parse a canonical dotted-decimal IPv4 host into its four octets. */
34
+ function parseIPv4(host: string): [number, number, number, number] | null {
35
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
36
+ if (!match) return null;
37
+ const octets: number[] = [];
38
+ for (let i = 1; i <= 4; i++) {
39
+ const value = Number(match[i]);
40
+ if (value > 255) return null;
41
+ octets.push(value);
42
+ }
43
+ return octets as [number, number, number, number];
44
+ }
45
+
46
+ /** Whether an IPv4 address falls in a private, loopback, or link-local range. */
47
+ function isPrivateIPv4(octets: [number, number, number, number]): boolean {
48
+ const [a, b] = octets;
49
+ if (a === 10) return true; // 10.0.0.0/8
50
+ if (a === 127) return true; // loopback
51
+ if (a === 0) return true; // "this network"
52
+ if (a === 169 && b === 254) return true; // link-local (incl. cloud metadata)
53
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
54
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
55
+ if (a >= 224) return true; // multicast / reserved
56
+ return false;
57
+ }
58
+
59
+ /** A host name that names the local host or an internal/reserved suffix. */
60
+ function isBlockedHostName(host: string): boolean {
61
+ const lower = host.toLowerCase();
62
+ if (lower === "localhost") return true;
63
+ return (
64
+ lower.endsWith(".localhost") ||
65
+ lower.endsWith(".internal") ||
66
+ lower.endsWith(".local")
67
+ );
68
+ }
69
+
70
+ /**
71
+ * Validate a delivery target URL: HTTPS only, and a public host. Throws a
72
+ * {@link DeliveryBlockedError} on any failure so the caller logs it and drops
73
+ * the row rather than retrying a target that can never be reached.
74
+ */
75
+ export function assertPublicHttpsTarget(url: string): URL {
76
+ let parsed: URL;
77
+ try {
78
+ parsed = new URL(url);
79
+ } catch {
80
+ throw new DeliveryBlockedError("invalid_url");
81
+ }
82
+ if (parsed.protocol !== "https:") {
83
+ throw new DeliveryBlockedError("disallowed_scheme");
84
+ }
85
+ const host = parsed.hostname;
86
+ const ipv4 = parseIPv4(host);
87
+ if (ipv4 && isPrivateIPv4(ipv4)) {
88
+ throw new DeliveryBlockedError("blocked_host");
89
+ }
90
+ // IPv6 loopback / unique-local / link-local, and bracketed forms.
91
+ const v6 = host.replace(/^\[|\]$/g, "").toLowerCase();
92
+ if (
93
+ v6 === "::1" ||
94
+ v6.startsWith("fe80:") ||
95
+ v6.startsWith("fc") ||
96
+ v6.startsWith("fd")
97
+ ) {
98
+ throw new DeliveryBlockedError("blocked_host");
99
+ }
100
+ if (isBlockedHostName(host)) {
101
+ throw new DeliveryBlockedError("blocked_host");
102
+ }
103
+ return parsed;
104
+ }
105
+
106
+ /** The outcome of a single delivery attempt. */
107
+ export type DeliveryResult =
108
+ | { readonly ok: true; readonly status: number }
109
+ | {
110
+ readonly ok: false;
111
+ readonly status: number;
112
+ readonly retryable: boolean;
113
+ };
114
+
115
+ /**
116
+ * Sign and `POST` one activity to a remote inbox. A `2xx` is success; `4xx`
117
+ * (except `408`/`429`) is a permanent failure (drop) since the peer rejected the
118
+ * request; `5xx`, `408`, `429`, and network errors are retryable.
119
+ */
120
+ export async function deliverActivity(
121
+ inboxUrl: string,
122
+ activityJson: string,
123
+ signer: SignerKey,
124
+ fetchImpl: typeof fetch,
125
+ now: () => number = () => Date.now(),
126
+ timeoutMs = 10_000,
127
+ ): Promise<DeliveryResult> {
128
+ // Throws DeliveryBlockedError for an unsafe target — the caller drops the row.
129
+ assertPublicHttpsTarget(inboxUrl);
130
+
131
+ const body = new TextEncoder().encode(activityJson);
132
+ const signed = await signRequest(inboxUrl, body, signer, { now });
133
+
134
+ let response: Response;
135
+ try {
136
+ response = await fetchImpl(inboxUrl, {
137
+ method: "POST",
138
+ headers: signed.headers,
139
+ body: signed.body as BufferSource,
140
+ // A hung peer must not pin the delivery worker; a timeout is retryable.
141
+ signal: AbortSignal.timeout(timeoutMs),
142
+ });
143
+ } catch {
144
+ return { ok: false, status: 0, retryable: true };
145
+ }
146
+
147
+ if (response.status >= 200 && response.status < 300) {
148
+ return { ok: true, status: response.status };
149
+ }
150
+ const retryable =
151
+ response.status >= 500 ||
152
+ response.status === 408 ||
153
+ response.status === 429;
154
+ return { ok: false, status: response.status, retryable };
155
+ }