@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.
- package/LICENSE +15 -0
- package/README.md +135 -0
- package/dist/as2.d.ts +117 -0
- package/dist/as2.d.ts.map +1 -0
- package/dist/as2.js +174 -0
- package/dist/as2.js.map +1 -0
- package/dist/config.d.ts +148 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +142 -0
- package/dist/config.js.map +1 -0
- package/dist/delivery.d.ts +43 -0
- package/dist/delivery.d.ts.map +1 -0
- package/dist/delivery.js +131 -0
- package/dist/delivery.js.map +1 -0
- package/dist/handler.d.ts +21 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +293 -0
- package/dist/handler.js.map +1 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/log.d.ts +57 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +53 -0
- package/dist/log.js.map +1 -0
- package/dist/nodeinfo.d.ts +33 -0
- package/dist/nodeinfo.d.ts.map +1 -0
- package/dist/nodeinfo.js +61 -0
- package/dist/nodeinfo.js.map +1 -0
- package/dist/object.d.ts +21 -0
- package/dist/object.d.ts.map +1 -0
- package/dist/object.js +722 -0
- package/dist/object.js.map +1 -0
- package/dist/signature.d.ts +108 -0
- package/dist/signature.d.ts.map +1 -0
- package/dist/signature.js +234 -0
- package/dist/signature.js.map +1 -0
- package/package.json +50 -0
- package/src/as2.ts +257 -0
- package/src/config.ts +291 -0
- package/src/delivery.ts +155 -0
- package/src/handler.ts +370 -0
- package/src/index.ts +90 -0
- package/src/log.ts +62 -0
- package/src/nodeinfo.ts +91 -0
- package/src/object.ts +883 -0
- package/src/signature.ts +355 -0
package/src/handler.ts
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stateless ActivityPub front door.
|
|
3
|
+
*
|
|
4
|
+
* It serves the static actor document and the NodeInfo discovery documents
|
|
5
|
+
* directly, verifies inbound `POST /inbox` HTTP signatures at the edge, and
|
|
6
|
+
* routes everything that touches authoritative state — collection reads, the
|
|
7
|
+
* inbox, the owner publish endpoint — to the per-actor Durable Object, which
|
|
8
|
+
* owns dedup, the follower/outbox collections, and the signed delivery queue.
|
|
9
|
+
* The handler routes purely on the request URL, so it is mountable under any
|
|
10
|
+
* path prefix (include the prefix in `baseUrl`).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { inboxLinkHeader } from "@dwk/ldn/discovery";
|
|
14
|
+
import { hostFromUrl, type LogFields } from "@dwk/log";
|
|
15
|
+
|
|
16
|
+
import { as2ContentType, buildActorDocument, type JsonValue } from "./as2";
|
|
17
|
+
import {
|
|
18
|
+
buildNodeInfo20,
|
|
19
|
+
buildNodeInfo21,
|
|
20
|
+
buildNodeInfoDiscovery,
|
|
21
|
+
type UsageCounts,
|
|
22
|
+
} from "./nodeinfo";
|
|
23
|
+
import {
|
|
24
|
+
INTERNAL_HEADERS,
|
|
25
|
+
resolveConfig,
|
|
26
|
+
type ActivityPubConfig,
|
|
27
|
+
type ActivityPubEnv,
|
|
28
|
+
type ForwardedConfig,
|
|
29
|
+
type ResolvedConfig,
|
|
30
|
+
} from "./config";
|
|
31
|
+
import {
|
|
32
|
+
ActivityPubLogEvent,
|
|
33
|
+
ApOutcome,
|
|
34
|
+
OUTCOME_ACTIVITY_HEADER,
|
|
35
|
+
OUTCOME_HEADER,
|
|
36
|
+
} from "./log";
|
|
37
|
+
import { verifyInboxSignature, type InboxRequest } from "./signature";
|
|
38
|
+
|
|
39
|
+
/** A `fetch`-compatible Worker handler. */
|
|
40
|
+
export type ActivityPubHandler = (
|
|
41
|
+
request: Request,
|
|
42
|
+
env: ActivityPubEnv,
|
|
43
|
+
ctx: ExecutionContext,
|
|
44
|
+
) => Promise<Response>;
|
|
45
|
+
|
|
46
|
+
const JSON_CONTENT_TYPE = "application/json; charset=utf-8";
|
|
47
|
+
|
|
48
|
+
/** Fail loudly if a required Cloudflare binding is missing. */
|
|
49
|
+
function assertBindings(env: ActivityPubEnv): void {
|
|
50
|
+
if (!env.ACTOR) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
"@dwk/activitypub: missing required Durable Object binding `ACTOR`",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The path portion of an IRI, for route matching. */
|
|
58
|
+
function pathOf(iri: string): string {
|
|
59
|
+
return new URL(iri).pathname;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function jsonResponse(
|
|
63
|
+
body: JsonValue,
|
|
64
|
+
contentType: string,
|
|
65
|
+
status = 200,
|
|
66
|
+
): Response {
|
|
67
|
+
return new Response(JSON.stringify(body), {
|
|
68
|
+
status,
|
|
69
|
+
headers: { "content-type": contentType },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function text(status: number, body: string): Response {
|
|
74
|
+
return new Response(body, {
|
|
75
|
+
status,
|
|
76
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Build the config subset the DO needs (including signing key material). */
|
|
81
|
+
function forwardedConfig(config: ResolvedConfig): ForwardedConfig {
|
|
82
|
+
return {
|
|
83
|
+
iris: config.iris,
|
|
84
|
+
actorName: config.actor.name ?? config.actor.username,
|
|
85
|
+
manuallyApprovesFollowers: config.actor.manuallyApprovesFollowers ?? false,
|
|
86
|
+
pageSize: config.pageSize,
|
|
87
|
+
deliveryMaxAttempts: config.deliveryMaxAttempts,
|
|
88
|
+
deliveryBaseDelayMs: config.deliveryBaseDelayMs,
|
|
89
|
+
keyId: config.iris.keyId,
|
|
90
|
+
sharedInbox: config.sharedInbox,
|
|
91
|
+
...(config.privateKeyPem ? { privateKeyPem: config.privateKeyPem } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Emit a structured event on both the logger and the metrics seam. */
|
|
96
|
+
function emit(
|
|
97
|
+
config: ResolvedConfig,
|
|
98
|
+
level: "info" | "warn",
|
|
99
|
+
event: string,
|
|
100
|
+
fields?: LogFields,
|
|
101
|
+
): void {
|
|
102
|
+
config.logger[level](event, fields);
|
|
103
|
+
config.metrics.count(event, fields);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Reach the per-actor Durable Object (one per actor IRI; no sharding). */
|
|
107
|
+
function actorStub(config: ResolvedConfig, env: ActivityPubEnv) {
|
|
108
|
+
const id = env.ACTOR.idFromName(config.iris.id);
|
|
109
|
+
return env.ACTOR.get(id);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Forward a request to the DO with the internal config (and optional) headers. */
|
|
113
|
+
function forwardToDo(
|
|
114
|
+
config: ResolvedConfig,
|
|
115
|
+
env: ActivityPubEnv,
|
|
116
|
+
url: string,
|
|
117
|
+
init: {
|
|
118
|
+
method: string;
|
|
119
|
+
body?: BodyInit | null;
|
|
120
|
+
extra?: Record<string, string>;
|
|
121
|
+
},
|
|
122
|
+
): Promise<Response> {
|
|
123
|
+
const headers = new Headers();
|
|
124
|
+
headers.set(INTERNAL_HEADERS.config, JSON.stringify(forwardedConfig(config)));
|
|
125
|
+
const accept = init.extra?.accept;
|
|
126
|
+
if (accept) headers.set("accept", accept);
|
|
127
|
+
for (const [k, v] of Object.entries(init.extra ?? {})) {
|
|
128
|
+
if (k !== "accept") headers.set(k, v);
|
|
129
|
+
}
|
|
130
|
+
const request = new Request(url, {
|
|
131
|
+
method: init.method,
|
|
132
|
+
headers,
|
|
133
|
+
...(init.body !== undefined ? { body: init.body } : {}),
|
|
134
|
+
});
|
|
135
|
+
return actorStub(config, env).fetch(request);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Translate the DO's internal inbound-outcome header into the matching log
|
|
140
|
+
* event, then strip it before the response reaches the peer.
|
|
141
|
+
*/
|
|
142
|
+
function logInboxOutcome(config: ResolvedConfig, response: Response): Response {
|
|
143
|
+
const outcome = response.headers.get(OUTCOME_HEADER);
|
|
144
|
+
if (!outcome) return response;
|
|
145
|
+
if (outcome === ApOutcome.InboxAccepted) {
|
|
146
|
+
emit(config, "info", ActivityPubLogEvent.InboxAccepted, {
|
|
147
|
+
activity: response.headers.get(OUTCOME_ACTIVITY_HEADER) ?? undefined,
|
|
148
|
+
});
|
|
149
|
+
} else if (outcome === ApOutcome.InboxDuplicate) {
|
|
150
|
+
emit(config, "info", ActivityPubLogEvent.InboxDuplicate);
|
|
151
|
+
}
|
|
152
|
+
const headers = new Headers(response.headers);
|
|
153
|
+
headers.delete(OUTCOME_HEADER);
|
|
154
|
+
headers.delete(OUTCOME_ACTIVITY_HEADER);
|
|
155
|
+
return new Response(response.body, {
|
|
156
|
+
status: response.status,
|
|
157
|
+
statusText: response.statusText,
|
|
158
|
+
headers,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Verify an inbound `POST /inbox` signature, via the override or the built-in. */
|
|
163
|
+
async function verifySignature(config: ResolvedConfig, inbox: InboxRequest) {
|
|
164
|
+
if (config.verifyInboxSignature) {
|
|
165
|
+
return config.verifyInboxSignature(inbox);
|
|
166
|
+
}
|
|
167
|
+
return verifyInboxSignature(inbox, config.keyResolver, {
|
|
168
|
+
clockSkewSeconds: config.clockSkewSeconds,
|
|
169
|
+
now: config.now,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Create the stateless ActivityPub front-door handler. The actor document and
|
|
175
|
+
* NodeInfo are served here; all authoritative state lives in the
|
|
176
|
+
* {@link ActivityPubObject} the request is routed to.
|
|
177
|
+
*/
|
|
178
|
+
export function createActivityPub(
|
|
179
|
+
config: ActivityPubConfig,
|
|
180
|
+
): ActivityPubHandler {
|
|
181
|
+
const resolved = resolveConfig(config);
|
|
182
|
+
const iris = resolved.iris;
|
|
183
|
+
|
|
184
|
+
const actorPath = pathOf(iris.id);
|
|
185
|
+
const inboxPath = pathOf(iris.inbox);
|
|
186
|
+
const outboxPath = pathOf(iris.outbox);
|
|
187
|
+
const followersPath = pathOf(iris.followers);
|
|
188
|
+
const followingPath = pathOf(iris.following);
|
|
189
|
+
const sharedInboxPath = resolved.sharedInbox
|
|
190
|
+
? pathOf(resolved.sharedInbox)
|
|
191
|
+
: undefined;
|
|
192
|
+
const nodeInfo20Path = new URL(`${resolved.baseUrl}/nodeinfo/2.0`).pathname;
|
|
193
|
+
const nodeInfo21Path = new URL(`${resolved.baseUrl}/nodeinfo/2.1`).pathname;
|
|
194
|
+
|
|
195
|
+
return async (request, env, _ctx) => {
|
|
196
|
+
assertBindings(env);
|
|
197
|
+
const url = new URL(request.url);
|
|
198
|
+
const path = url.pathname;
|
|
199
|
+
const method = request.method.toUpperCase();
|
|
200
|
+
|
|
201
|
+
// --- NodeInfo (static discovery + mostly-static 2.1 doc) ---------------
|
|
202
|
+
if (path === "/.well-known/nodeinfo" && method === "GET") {
|
|
203
|
+
return jsonResponse(
|
|
204
|
+
buildNodeInfoDiscovery(resolved.baseUrl),
|
|
205
|
+
JSON_CONTENT_TYPE,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (
|
|
209
|
+
(path === nodeInfo20Path || path === nodeInfo21Path) &&
|
|
210
|
+
method === "GET"
|
|
211
|
+
) {
|
|
212
|
+
const usage = await nodeInfoUsage(resolved, env);
|
|
213
|
+
const build = path === nodeInfo20Path ? buildNodeInfo20 : buildNodeInfo21;
|
|
214
|
+
return jsonResponse(build(resolved.software, usage), JSON_CONTENT_TYPE);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --- Actor document (static, served at the edge) ------------------------
|
|
218
|
+
if (path === actorPath) {
|
|
219
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
220
|
+
return text(405, "Method Not Allowed");
|
|
221
|
+
}
|
|
222
|
+
// Serve AS2 to federation peers; an HTML profile page is out of scope.
|
|
223
|
+
// A strict client may still content-negotiate for the JSON-LD profile
|
|
224
|
+
// variant (§3.2), so the response Content-Type honors `Accept`.
|
|
225
|
+
const body = JSON.stringify(
|
|
226
|
+
buildActorDocument(iris, resolved.actor, resolved.publicKeyPem, {
|
|
227
|
+
sharedInbox: resolved.sharedInbox,
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
return new Response(method === "HEAD" ? null : body, {
|
|
231
|
+
status: 200,
|
|
232
|
+
headers: {
|
|
233
|
+
"content-type": as2ContentType(request.headers.get("accept")),
|
|
234
|
+
// Advertise the actor's inbox via LDN discovery too, so a plain
|
|
235
|
+
// Linked Data Notifications sender (not just an ActivityPub peer) can
|
|
236
|
+
// find it from the `Link` header without parsing the AS2 body.
|
|
237
|
+
link: inboxLinkHeader(iris.inbox),
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// --- Inbox(es): verify signature at the edge, then route to the DO ------
|
|
243
|
+
// Both the actor's personal inbox and the optional instance-level shared
|
|
244
|
+
// inbox (§7.1.3) are handled identically: the single actor is the only
|
|
245
|
+
// recipient, so a batched delivery to the shared inbox is processed for it.
|
|
246
|
+
const isPersonalInbox = path === inboxPath;
|
|
247
|
+
const isSharedInbox =
|
|
248
|
+
sharedInboxPath !== undefined && path === sharedInboxPath;
|
|
249
|
+
if (isPersonalInbox || isSharedInbox) {
|
|
250
|
+
if (method !== "POST") {
|
|
251
|
+
// The personal inbox lets the DO answer 405; the shared inbox is
|
|
252
|
+
// write-only with no DO collection behind it, so answer here.
|
|
253
|
+
if (isSharedInbox) return text(405, "Method Not Allowed");
|
|
254
|
+
return forwardToDo(resolved, env, request.url, { method });
|
|
255
|
+
}
|
|
256
|
+
const bodyBytes = new Uint8Array(await request.arrayBuffer());
|
|
257
|
+
const inbox: InboxRequest = {
|
|
258
|
+
method,
|
|
259
|
+
path: `${url.pathname}${url.search}`,
|
|
260
|
+
headers: request.headers,
|
|
261
|
+
body: bodyBytes,
|
|
262
|
+
};
|
|
263
|
+
const result = await verifySignature(resolved, inbox);
|
|
264
|
+
if (!result.ok) {
|
|
265
|
+
emit(resolved, "warn", ActivityPubLogEvent.SignatureRejected, {
|
|
266
|
+
reason: result.reason,
|
|
267
|
+
});
|
|
268
|
+
return text(401, `invalid_signature: ${result.reason}`);
|
|
269
|
+
}
|
|
270
|
+
emit(resolved, "info", ActivityPubLogEvent.SignatureAccepted, {
|
|
271
|
+
actorHost: hostFromUrl(result.actor),
|
|
272
|
+
});
|
|
273
|
+
const response = await forwardToDo(resolved, env, request.url, {
|
|
274
|
+
method,
|
|
275
|
+
body: bodyBytes as BufferSource,
|
|
276
|
+
extra: { [INTERNAL_HEADERS.signedActor]: result.actor },
|
|
277
|
+
});
|
|
278
|
+
return logInboxOutcome(resolved, response);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// --- Owner publish endpoint (the micropub → Create fan-out seam) --------
|
|
282
|
+
if (path === outboxPath && method === "POST") {
|
|
283
|
+
if (!resolved.publishToken) {
|
|
284
|
+
emit(resolved, "warn", ActivityPubLogEvent.PublishRejected, {
|
|
285
|
+
reason: "disabled",
|
|
286
|
+
});
|
|
287
|
+
return text(404, "Not Found");
|
|
288
|
+
}
|
|
289
|
+
if (!(await authorizedPublish(request, resolved.publishToken))) {
|
|
290
|
+
emit(resolved, "warn", ActivityPubLogEvent.PublishRejected, {
|
|
291
|
+
reason: "unauthorized",
|
|
292
|
+
});
|
|
293
|
+
return text(401, "Unauthorized");
|
|
294
|
+
}
|
|
295
|
+
const body = new Uint8Array(await request.arrayBuffer());
|
|
296
|
+
return forwardToDo(resolved, env, request.url, {
|
|
297
|
+
method,
|
|
298
|
+
body: body as BufferSource,
|
|
299
|
+
extra: { [INTERNAL_HEADERS.publish]: "1" },
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// --- Collection reads (authoritative; routed to the DO) -----------------
|
|
304
|
+
if (
|
|
305
|
+
method === "GET" &&
|
|
306
|
+
(path === outboxPath || path === followersPath || path === followingPath)
|
|
307
|
+
) {
|
|
308
|
+
return forwardToDo(resolved, env, request.url, { method });
|
|
309
|
+
}
|
|
310
|
+
if (path === inboxPath || path === outboxPath) {
|
|
311
|
+
// Non-GET/POST on a collection: let the DO answer 405.
|
|
312
|
+
return forwardToDo(resolved, env, request.url, { method });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return text(404, "Not Found");
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Whether a publish request carries the configured bearer token. */
|
|
320
|
+
async function authorizedPublish(
|
|
321
|
+
request: Request,
|
|
322
|
+
token: string,
|
|
323
|
+
): Promise<boolean> {
|
|
324
|
+
const header = request.headers.get("authorization");
|
|
325
|
+
if (!header) return false;
|
|
326
|
+
const match = /^Bearer\s+(.+)$/i.exec(header);
|
|
327
|
+
if (match === null) return false;
|
|
328
|
+
return constantTimeEqual(match[1] as string, token);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Constant-time string comparison that leaks neither the content **nor the
|
|
333
|
+
* length** of the secret. Both inputs are hashed to fixed-length SHA-256
|
|
334
|
+
* digests (via WebCrypto, so the package needs no `node:crypto` /
|
|
335
|
+
* `nodejs_compat`) and the digests are compared without an early return.
|
|
336
|
+
*/
|
|
337
|
+
async function constantTimeEqual(a: string, b: string): Promise<boolean> {
|
|
338
|
+
const enc = new TextEncoder();
|
|
339
|
+
const [ha, hb] = await Promise.all([
|
|
340
|
+
crypto.subtle.digest("SHA-256", enc.encode(a) as BufferSource),
|
|
341
|
+
crypto.subtle.digest("SHA-256", enc.encode(b) as BufferSource),
|
|
342
|
+
]);
|
|
343
|
+
const va = new Uint8Array(ha);
|
|
344
|
+
const vb = new Uint8Array(hb);
|
|
345
|
+
let diff = 0;
|
|
346
|
+
for (let i = 0; i < va.length; i++)
|
|
347
|
+
diff |= (va[i] as number) ^ (vb[i] as number);
|
|
348
|
+
return diff === 0;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Fetch live usage counts from the DO for the NodeInfo document. */
|
|
352
|
+
async function nodeInfoUsage(
|
|
353
|
+
config: ResolvedConfig,
|
|
354
|
+
env: ActivityPubEnv,
|
|
355
|
+
): Promise<UsageCounts> {
|
|
356
|
+
try {
|
|
357
|
+
const statsUrl = `${config.iris.id}/__stats`;
|
|
358
|
+
const response = await forwardToDo(config, env, statsUrl, {
|
|
359
|
+
method: "GET",
|
|
360
|
+
});
|
|
361
|
+
if (!response.ok) return {};
|
|
362
|
+
const stats = (await response.json()) as UsageCounts;
|
|
363
|
+
return {
|
|
364
|
+
users: typeof stats.users === "number" ? stats.users : 1,
|
|
365
|
+
localPosts: typeof stats.localPosts === "number" ? stats.localPosts : 0,
|
|
366
|
+
};
|
|
367
|
+
} catch {
|
|
368
|
+
return {};
|
|
369
|
+
}
|
|
370
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/activitypub` — edge-native ActivityPub actor.
|
|
3
|
+
*
|
|
4
|
+
* A native [ActivityPub](https://www.w3.org/TR/activitypub/) actor rooted at the
|
|
5
|
+
* user's own domain, making the self-owned presence a first-class fediverse
|
|
6
|
+
* citizen (followers, replies, boosts) rather than a bridged guest. It mirrors
|
|
7
|
+
* the architecture proven in `@dwk/solid-pod`: a stateless Worker front door
|
|
8
|
+
* (routing + edge HTTP-signature verification) over a per-actor Durable Object
|
|
9
|
+
* that is the consistency authority for activity-`id` dedup, the
|
|
10
|
+
* follower/following/outbox collections, and the signed outbound delivery queue
|
|
11
|
+
* (retry/backoff via DO alarms). This is the second `@dwk` package to ship a
|
|
12
|
+
* Durable Object.
|
|
13
|
+
*
|
|
14
|
+
* The package is **not** protocol-agnostic: it is the ActivityPub endpoint. v1
|
|
15
|
+
* covers server-to-server federation (inbound `Follow`/`Undo`/`Create`/`Like`/
|
|
16
|
+
* `Announce`/`Delete` with signature verification and dedup; auto-`Accept` of
|
|
17
|
+
* follows; signed fan-out delivery), the actor document and paged
|
|
18
|
+
* `OrderedCollection`s, and NodeInfo discovery. Client-to-server authoring is
|
|
19
|
+
* out of scope — `@dwk/micropub` covers authoring, and the owner publish
|
|
20
|
+
* endpoint (`POST <actor>/outbox`) is the publish → `Create` fan-out seam.
|
|
21
|
+
*
|
|
22
|
+
* HTTP Message Signatures are implemented inline against the de-facto fediverse
|
|
23
|
+
* `draft-cavage` profile so the actor federates today; signing/verification sit
|
|
24
|
+
* behind the {@link ActivityPubConfig.verifyInboxSignature} seam so the
|
|
25
|
+
* forthcoming cross-standard `@dwk/http-signatures` package (issue #59) can be
|
|
26
|
+
* swapped in unchanged.
|
|
27
|
+
*
|
|
28
|
+
* @see spec/packages/activitypub.md
|
|
29
|
+
* @packageDocumentation
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export { createActivityPub, type ActivityPubHandler } from "./handler";
|
|
33
|
+
export { ActivityPubObject } from "./object";
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
resolveConfig,
|
|
37
|
+
deriveIris,
|
|
38
|
+
type ActivityPubConfig,
|
|
39
|
+
type ActivityPubEnv,
|
|
40
|
+
type ResolvedConfig,
|
|
41
|
+
type InboxVerifier,
|
|
42
|
+
} from "./config";
|
|
43
|
+
|
|
44
|
+
export {
|
|
45
|
+
AS2_NS,
|
|
46
|
+
AS2_CONTENT_TYPE,
|
|
47
|
+
AS2_LD_CONTENT_TYPE,
|
|
48
|
+
PUBLIC_AUDIENCE,
|
|
49
|
+
buildActorDocument,
|
|
50
|
+
buildCollection,
|
|
51
|
+
buildCollectionPage,
|
|
52
|
+
wantsActivityJson,
|
|
53
|
+
as2ContentType,
|
|
54
|
+
type ActorProfile,
|
|
55
|
+
type ActorIris,
|
|
56
|
+
type ActorDocumentOptions,
|
|
57
|
+
type ActivityObject,
|
|
58
|
+
} from "./as2";
|
|
59
|
+
|
|
60
|
+
export {
|
|
61
|
+
buildNodeInfoDiscovery,
|
|
62
|
+
buildNodeInfo20,
|
|
63
|
+
buildNodeInfo21,
|
|
64
|
+
type SoftwareInfo,
|
|
65
|
+
type UsageCounts,
|
|
66
|
+
} from "./nodeinfo";
|
|
67
|
+
|
|
68
|
+
export {
|
|
69
|
+
signRequest,
|
|
70
|
+
verifyInboxSignature,
|
|
71
|
+
digestHeader,
|
|
72
|
+
importPublicKey,
|
|
73
|
+
importPrivateKey,
|
|
74
|
+
type KeyResolver,
|
|
75
|
+
type ResolvedKey,
|
|
76
|
+
type VerifyResult,
|
|
77
|
+
type VerifyFailureReason,
|
|
78
|
+
type InboxRequest,
|
|
79
|
+
type SignerKey,
|
|
80
|
+
} from "./signature";
|
|
81
|
+
|
|
82
|
+
export {
|
|
83
|
+
deliverActivity,
|
|
84
|
+
assertPublicHttpsTarget,
|
|
85
|
+
DeliveryBlockedError,
|
|
86
|
+
type DeliveryResult,
|
|
87
|
+
} from "./delivery";
|
|
88
|
+
|
|
89
|
+
export { ActivityPubLogEvent } from "./log";
|
|
90
|
+
export type { Logger, Metrics } from "@dwk/log";
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/activitypub` — structured observability event taxonomy.
|
|
3
|
+
*
|
|
4
|
+
* An ActivityPub actor verifies HTTP signatures at the edge and federates
|
|
5
|
+
* activities from the Durable Object; a rejected signature or a failed delivery
|
|
6
|
+
* that is silently swallowed is an operational and security blind spot. Logging
|
|
7
|
+
* and metrics are opt-in via an injected {@link Logger}/{@link Metrics} (see
|
|
8
|
+
* `@dwk/log`), wired once at the composition boundary — the stateless front
|
|
9
|
+
* door — and **share this one vocabulary** so a log line and its counter line
|
|
10
|
+
* up.
|
|
11
|
+
*
|
|
12
|
+
* Because the Durable Object cannot receive injected functions across the
|
|
13
|
+
* isolate boundary, it signals its inbound/delivery outcomes back to the front
|
|
14
|
+
* door via an internal response header ({@link ApOutcome}); the front door emits
|
|
15
|
+
* the events and strips the header. Fields follow the redaction policy — only
|
|
16
|
+
* machine-readable reason codes, activity types, and sanitized hosts; never key
|
|
17
|
+
* material, tokens, or full bodies. See `spec/observability.md`.
|
|
18
|
+
*
|
|
19
|
+
* @packageDocumentation
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Stable event names emitted by `@dwk/activitypub`. */
|
|
23
|
+
export const ActivityPubLogEvent = {
|
|
24
|
+
/** An inbound `POST /inbox` signature failed verification. Field: `reason`. */
|
|
25
|
+
SignatureRejected: "activitypub.signature.rejected",
|
|
26
|
+
/** An inbound `POST /inbox` signature verified. Field: `actorHost`. */
|
|
27
|
+
SignatureAccepted: "activitypub.signature.accepted",
|
|
28
|
+
/** An inbound activity was accepted by the DO. Fields: `activity` (type). */
|
|
29
|
+
InboxAccepted: "activitypub.inbox.accepted",
|
|
30
|
+
/** An inbound activity was a duplicate (deduped by `id`). */
|
|
31
|
+
InboxDuplicate: "activitypub.inbox.duplicate",
|
|
32
|
+
/** A delivery to a remote inbox succeeded. Field: `targetHost`. */
|
|
33
|
+
DeliverySucceeded: "activitypub.delivery.succeeded",
|
|
34
|
+
/** A delivery attempt failed and will be retried or dropped. Fields: `targetHost`, `dropped`. */
|
|
35
|
+
DeliveryFailed: "activitypub.delivery.failed",
|
|
36
|
+
/** A delivery target was refused on SSRF grounds. Field: `reason`. */
|
|
37
|
+
DeliveryBlocked: "activitypub.delivery.blocked",
|
|
38
|
+
/** An owner publish request was refused (bad/absent token). */
|
|
39
|
+
PublishRejected: "activitypub.publish.rejected",
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
/** Union of the event-name string literals in {@link ActivityPubLogEvent}. */
|
|
43
|
+
export type ActivityPubLogEvent =
|
|
44
|
+
(typeof ActivityPubLogEvent)[keyof typeof ActivityPubLogEvent];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Machine-readable outcome values the DO sets on the internal outcome header so
|
|
48
|
+
* the front door can emit the matching {@link ActivityPubLogEvent}. Internal to
|
|
49
|
+
* the DO↔front-door contract; stripped before the response reaches the client.
|
|
50
|
+
*/
|
|
51
|
+
export const ApOutcome = {
|
|
52
|
+
InboxAccepted: "inbox_accepted",
|
|
53
|
+
InboxDuplicate: "inbox_duplicate",
|
|
54
|
+
} as const;
|
|
55
|
+
|
|
56
|
+
/** Union of the outcome string literals in {@link ApOutcome}. */
|
|
57
|
+
export type ApOutcome = (typeof ApOutcome)[keyof typeof ApOutcome];
|
|
58
|
+
|
|
59
|
+
/** Internal header the DO uses to report an inbound outcome to the front door. */
|
|
60
|
+
export const OUTCOME_HEADER = "x-ap-outcome";
|
|
61
|
+
/** Internal header carrying the accepted activity type for logging. */
|
|
62
|
+
export const OUTCOME_ACTIVITY_HEADER = "x-ap-outcome-activity";
|
package/src/nodeinfo.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NodeInfo discovery documents for `@dwk/activitypub`.
|
|
3
|
+
*
|
|
4
|
+
* NodeInfo lets a peer learn what software a domain runs. The documents are
|
|
5
|
+
* **largely static** — static enough for a generator like Anglesite to emit —
|
|
6
|
+
* with only the live `usage` counts being dynamic. This module builds the
|
|
7
|
+
* `/.well-known/nodeinfo` discovery pointer (advertising both the 2.0 and 2.1
|
|
8
|
+
* schemas, since many consumers still request 2.0) and the matching `nodeinfo`
|
|
9
|
+
* documents from plain data; the consumer decides whether to fill in live counts
|
|
10
|
+
* (from the DO) or omit them.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { JsonValue } from "./as2";
|
|
14
|
+
|
|
15
|
+
/** Identifies the running software in the NodeInfo `software` block. */
|
|
16
|
+
export interface SoftwareInfo {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly version: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Dynamic usage counts; omitted entirely when not supplied. */
|
|
22
|
+
export interface UsageCounts {
|
|
23
|
+
readonly users?: number;
|
|
24
|
+
readonly localPosts?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Schema rel for the NodeInfo 2.0 document. */
|
|
28
|
+
const NODEINFO_REL_20 = "http://nodeinfo.diaspora.software/ns/schema/2.0";
|
|
29
|
+
/** Schema rel for the NodeInfo 2.1 document. */
|
|
30
|
+
const NODEINFO_REL_21 = "http://nodeinfo.diaspora.software/ns/schema/2.1";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the `/.well-known/nodeinfo` discovery document. NodeInfo allows multiple
|
|
34
|
+
* `links` entries, so both the 2.0 and 2.1 documents are advertised: many
|
|
35
|
+
* consumers still request 2.0, and listing both maximizes interop.
|
|
36
|
+
*/
|
|
37
|
+
export function buildNodeInfoDiscovery(
|
|
38
|
+
baseUrl: string,
|
|
39
|
+
): Record<string, JsonValue> {
|
|
40
|
+
return {
|
|
41
|
+
links: [
|
|
42
|
+
{ rel: NODEINFO_REL_20, href: `${baseUrl}/nodeinfo/2.0` },
|
|
43
|
+
{ rel: NODEINFO_REL_21, href: `${baseUrl}/nodeinfo/2.1` },
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build a NodeInfo document for the given schema `version`. `protocols` is fixed
|
|
50
|
+
* to `activitypub`; `usage` is included only when counts are supplied (a
|
|
51
|
+
* deployment that does not want to wake the DO for live numbers omits them). The
|
|
52
|
+
* 2.0 and 2.1 schemas agree on every field we emit, so the only difference is
|
|
53
|
+
* the `version` string.
|
|
54
|
+
*/
|
|
55
|
+
function buildNodeInfo(
|
|
56
|
+
version: "2.0" | "2.1",
|
|
57
|
+
software: SoftwareInfo,
|
|
58
|
+
usage: UsageCounts,
|
|
59
|
+
): Record<string, JsonValue> {
|
|
60
|
+
const doc: Record<string, JsonValue> = {
|
|
61
|
+
version,
|
|
62
|
+
software: { name: software.name, version: software.version },
|
|
63
|
+
protocols: ["activitypub"],
|
|
64
|
+
services: { inbound: [], outbound: [] },
|
|
65
|
+
openRegistrations: false,
|
|
66
|
+
metadata: {},
|
|
67
|
+
};
|
|
68
|
+
if (usage.users !== undefined || usage.localPosts !== undefined) {
|
|
69
|
+
doc.usage = {
|
|
70
|
+
users: { total: usage.users ?? 0 },
|
|
71
|
+
localPosts: usage.localPosts ?? 0,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return doc;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Build the `nodeinfo/2.0` document (see {@link buildNodeInfo}). */
|
|
78
|
+
export function buildNodeInfo20(
|
|
79
|
+
software: SoftwareInfo,
|
|
80
|
+
usage: UsageCounts = {},
|
|
81
|
+
): Record<string, JsonValue> {
|
|
82
|
+
return buildNodeInfo("2.0", software, usage);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Build the `nodeinfo/2.1` document (see {@link buildNodeInfo}). */
|
|
86
|
+
export function buildNodeInfo21(
|
|
87
|
+
software: SoftwareInfo,
|
|
88
|
+
usage: UsageCounts = {},
|
|
89
|
+
): Record<string, JsonValue> {
|
|
90
|
+
return buildNodeInfo("2.1", software, usage);
|
|
91
|
+
}
|