@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/dist/config.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
import { noopLogger, noopMetrics } from "@dwk/log";
|
|
12
|
+
/**
|
|
13
|
+
* Internal headers the trusted front door uses to hand verified facts and the
|
|
14
|
+
* config subset the DO needs (including signing key material, which never leaves
|
|
15
|
+
* Cloudflare's network) to the Durable Object.
|
|
16
|
+
*/
|
|
17
|
+
export const INTERNAL_HEADERS = {
|
|
18
|
+
/** The verified actor IRI that signed an inbound `POST /inbox` (absent ⇒ unverified). */
|
|
19
|
+
signedActor: "x-ap-signed-actor",
|
|
20
|
+
/** JSON config subset the DO needs (IRIs, delivery policy, signing key). */
|
|
21
|
+
config: "x-ap-config",
|
|
22
|
+
/** Marks an owner-authorized publish request (`POST <actor>/outbox`). */
|
|
23
|
+
publish: "x-ap-publish",
|
|
24
|
+
};
|
|
25
|
+
const DEFAULT_PAGE_SIZE = 50;
|
|
26
|
+
const DEFAULT_MAX_ATTEMPTS = 8;
|
|
27
|
+
const DEFAULT_BASE_DELAY_MS = 60_000;
|
|
28
|
+
const DEFAULT_CLOCK_SKEW_SECONDS = 300;
|
|
29
|
+
const DEFAULT_SOFTWARE = {
|
|
30
|
+
name: "dwk-activitypub",
|
|
31
|
+
version: "0.0.0",
|
|
32
|
+
};
|
|
33
|
+
/** Strip a trailing slash so path joins are unambiguous. */
|
|
34
|
+
function normalizeBaseUrl(baseUrl) {
|
|
35
|
+
return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
36
|
+
}
|
|
37
|
+
/** Derive the actor + collection IRIs from a base URL and username. */
|
|
38
|
+
export function deriveIris(baseUrl, username) {
|
|
39
|
+
const id = `${baseUrl}/users/${encodeURIComponent(username)}`;
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
inbox: `${id}/inbox`,
|
|
43
|
+
outbox: `${id}/outbox`,
|
|
44
|
+
followers: `${id}/followers`,
|
|
45
|
+
following: `${id}/following`,
|
|
46
|
+
keyId: `${id}#main-key`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The default key resolver: fetch the `keyId` (an actor or fragment IRI) as AS2
|
|
51
|
+
* and read its embedded `publicKey`. Returns `null` when the document cannot be
|
|
52
|
+
* fetched or carries no usable key, which the verifier maps to a rejection.
|
|
53
|
+
*/
|
|
54
|
+
function defaultKeyResolver(fetchImpl) {
|
|
55
|
+
return async (keyId) => {
|
|
56
|
+
// The key IRI is the actor (or key) document URL with its fragment
|
|
57
|
+
// stripped. Parse it as a URL so the fragment is removed per the URL spec
|
|
58
|
+
// (rather than by string surgery) and an unparseable `keyId` is rejected
|
|
59
|
+
// before it reaches `fetch`.
|
|
60
|
+
let actorUrl;
|
|
61
|
+
try {
|
|
62
|
+
const url = new URL(keyId);
|
|
63
|
+
// Only ever dereference a remote actor/key document over HTTP(S); reject
|
|
64
|
+
// other schemes (`file:`, `data:`, …) outright so a crafted `keyId`
|
|
65
|
+
// cannot redirect the fetch at a non-network resource.
|
|
66
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
67
|
+
return null;
|
|
68
|
+
url.hash = "";
|
|
69
|
+
actorUrl = url.href;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
let response;
|
|
75
|
+
try {
|
|
76
|
+
response = await fetchImpl(actorUrl, {
|
|
77
|
+
headers: { accept: "application/activity+json" },
|
|
78
|
+
// Bound key resolution so a slow remote cannot stall inbox verification.
|
|
79
|
+
signal: AbortSignal.timeout(10_000),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (!response.ok)
|
|
86
|
+
return null;
|
|
87
|
+
let doc;
|
|
88
|
+
try {
|
|
89
|
+
doc = await response.json();
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
if (!doc || typeof doc !== "object")
|
|
95
|
+
return null;
|
|
96
|
+
const publicKey = doc.publicKey;
|
|
97
|
+
if (!publicKey || typeof publicKey !== "object")
|
|
98
|
+
return null;
|
|
99
|
+
const pem = publicKey.publicKeyPem;
|
|
100
|
+
const owner = publicKey.owner ??
|
|
101
|
+
doc.id;
|
|
102
|
+
if (typeof pem !== "string" || typeof owner !== "string")
|
|
103
|
+
return null;
|
|
104
|
+
return { owner, publicKeyPem: pem };
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/** Apply defaults and derive IRIs from raw {@link ActivityPubConfig}. */
|
|
108
|
+
export function resolveConfig(config) {
|
|
109
|
+
if (!config.baseUrl) {
|
|
110
|
+
throw new Error("@dwk/activitypub: `baseUrl` is required");
|
|
111
|
+
}
|
|
112
|
+
if (!config.actor || !config.actor.username) {
|
|
113
|
+
throw new Error("@dwk/activitypub: `actor.username` is required");
|
|
114
|
+
}
|
|
115
|
+
if (!config.publicKeyPem) {
|
|
116
|
+
throw new Error("@dwk/activitypub: `publicKeyPem` is required");
|
|
117
|
+
}
|
|
118
|
+
const baseUrl = normalizeBaseUrl(config.baseUrl);
|
|
119
|
+
const fetchImpl = config.fetch ?? fetch;
|
|
120
|
+
const sharedInbox = (config.sharedInbox ?? true) ? `${baseUrl}/inbox` : undefined;
|
|
121
|
+
return {
|
|
122
|
+
baseUrl,
|
|
123
|
+
actor: config.actor,
|
|
124
|
+
iris: deriveIris(baseUrl, config.actor.username),
|
|
125
|
+
sharedInbox,
|
|
126
|
+
publicKeyPem: config.publicKeyPem,
|
|
127
|
+
privateKeyPem: config.privateKeyPem,
|
|
128
|
+
publishToken: config.publishToken,
|
|
129
|
+
pageSize: config.pageSize ?? DEFAULT_PAGE_SIZE,
|
|
130
|
+
deliveryMaxAttempts: config.deliveryMaxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
|
131
|
+
deliveryBaseDelayMs: config.deliveryBaseDelayMs ?? DEFAULT_BASE_DELAY_MS,
|
|
132
|
+
clockSkewSeconds: config.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS,
|
|
133
|
+
software: config.software ?? DEFAULT_SOFTWARE,
|
|
134
|
+
keyResolver: config.keyResolver ?? defaultKeyResolver(fetchImpl),
|
|
135
|
+
verifyInboxSignature: config.verifyInboxSignature,
|
|
136
|
+
fetch: fetchImpl,
|
|
137
|
+
now: config.now ?? (() => Date.now()),
|
|
138
|
+
logger: config.logger ?? noopLogger,
|
|
139
|
+
metrics: config.metrics ?? noopMetrics,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
import { type SignerKey } from "./signature";
|
|
15
|
+
/** Machine-readable cause of a blocked delivery target. */
|
|
16
|
+
export type BlockedReason = "invalid_url" | "disallowed_scheme" | "blocked_host";
|
|
17
|
+
/** Raised when a delivery target is refused on SSRF grounds. */
|
|
18
|
+
export declare class DeliveryBlockedError extends Error {
|
|
19
|
+
readonly reason: BlockedReason;
|
|
20
|
+
constructor(reason: BlockedReason);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Validate a delivery target URL: HTTPS only, and a public host. Throws a
|
|
24
|
+
* {@link DeliveryBlockedError} on any failure so the caller logs it and drops
|
|
25
|
+
* the row rather than retrying a target that can never be reached.
|
|
26
|
+
*/
|
|
27
|
+
export declare function assertPublicHttpsTarget(url: string): URL;
|
|
28
|
+
/** The outcome of a single delivery attempt. */
|
|
29
|
+
export type DeliveryResult = {
|
|
30
|
+
readonly ok: true;
|
|
31
|
+
readonly status: number;
|
|
32
|
+
} | {
|
|
33
|
+
readonly ok: false;
|
|
34
|
+
readonly status: number;
|
|
35
|
+
readonly retryable: boolean;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Sign and `POST` one activity to a remote inbox. A `2xx` is success; `4xx`
|
|
39
|
+
* (except `408`/`429`) is a permanent failure (drop) since the peer rejected the
|
|
40
|
+
* request; `5xx`, `408`, `429`, and network errors are retryable.
|
|
41
|
+
*/
|
|
42
|
+
export declare function deliverActivity(inboxUrl: string, activityJson: string, signer: SignerKey, fetchImpl: typeof fetch, now?: () => number, timeoutMs?: number): Promise<DeliveryResult>;
|
|
43
|
+
//# sourceMappingURL=delivery.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"delivery.d.ts","sourceRoot":"","sources":["../src/delivery.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAe,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AAE1D,2DAA2D;AAC3D,MAAM,MAAM,aAAa,GACrB,aAAa,GACb,mBAAmB,GACnB,cAAc,CAAC;AAEnB,gEAAgE;AAChE,qBAAa,oBAAqB,SAAQ,KAAK;IAC7C,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;gBACnB,MAAM,EAAE,aAAa;CAKlC;AAuCD;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CA6BxD;AAED,gDAAgD;AAChD,MAAM,MAAM,cAAc,GACtB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC9C;IACE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;CAC7B,CAAC;AAEN;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,SAAS,EACjB,SAAS,EAAE,OAAO,KAAK,EACvB,GAAG,GAAE,MAAM,MAAyB,EACpC,SAAS,SAAS,GACjB,OAAO,CAAC,cAAc,CAAC,CA4BzB"}
|
package/dist/delivery.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
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
|
+
import { signRequest } from "./signature";
|
|
15
|
+
/** Raised when a delivery target is refused on SSRF grounds. */
|
|
16
|
+
export class DeliveryBlockedError extends Error {
|
|
17
|
+
reason;
|
|
18
|
+
constructor(reason) {
|
|
19
|
+
super(`delivery target blocked: ${reason}`);
|
|
20
|
+
this.name = "DeliveryBlockedError";
|
|
21
|
+
this.reason = reason;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Parse a canonical dotted-decimal IPv4 host into its four octets. */
|
|
25
|
+
function parseIPv4(host) {
|
|
26
|
+
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
27
|
+
if (!match)
|
|
28
|
+
return null;
|
|
29
|
+
const octets = [];
|
|
30
|
+
for (let i = 1; i <= 4; i++) {
|
|
31
|
+
const value = Number(match[i]);
|
|
32
|
+
if (value > 255)
|
|
33
|
+
return null;
|
|
34
|
+
octets.push(value);
|
|
35
|
+
}
|
|
36
|
+
return octets;
|
|
37
|
+
}
|
|
38
|
+
/** Whether an IPv4 address falls in a private, loopback, or link-local range. */
|
|
39
|
+
function isPrivateIPv4(octets) {
|
|
40
|
+
const [a, b] = octets;
|
|
41
|
+
if (a === 10)
|
|
42
|
+
return true; // 10.0.0.0/8
|
|
43
|
+
if (a === 127)
|
|
44
|
+
return true; // loopback
|
|
45
|
+
if (a === 0)
|
|
46
|
+
return true; // "this network"
|
|
47
|
+
if (a === 169 && b === 254)
|
|
48
|
+
return true; // link-local (incl. cloud metadata)
|
|
49
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
50
|
+
return true; // 172.16.0.0/12
|
|
51
|
+
if (a === 192 && b === 168)
|
|
52
|
+
return true; // 192.168.0.0/16
|
|
53
|
+
if (a >= 224)
|
|
54
|
+
return true; // multicast / reserved
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
/** A host name that names the local host or an internal/reserved suffix. */
|
|
58
|
+
function isBlockedHostName(host) {
|
|
59
|
+
const lower = host.toLowerCase();
|
|
60
|
+
if (lower === "localhost")
|
|
61
|
+
return true;
|
|
62
|
+
return (lower.endsWith(".localhost") ||
|
|
63
|
+
lower.endsWith(".internal") ||
|
|
64
|
+
lower.endsWith(".local"));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Validate a delivery target URL: HTTPS only, and a public host. Throws a
|
|
68
|
+
* {@link DeliveryBlockedError} on any failure so the caller logs it and drops
|
|
69
|
+
* the row rather than retrying a target that can never be reached.
|
|
70
|
+
*/
|
|
71
|
+
export function assertPublicHttpsTarget(url) {
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = new URL(url);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new DeliveryBlockedError("invalid_url");
|
|
78
|
+
}
|
|
79
|
+
if (parsed.protocol !== "https:") {
|
|
80
|
+
throw new DeliveryBlockedError("disallowed_scheme");
|
|
81
|
+
}
|
|
82
|
+
const host = parsed.hostname;
|
|
83
|
+
const ipv4 = parseIPv4(host);
|
|
84
|
+
if (ipv4 && isPrivateIPv4(ipv4)) {
|
|
85
|
+
throw new DeliveryBlockedError("blocked_host");
|
|
86
|
+
}
|
|
87
|
+
// IPv6 loopback / unique-local / link-local, and bracketed forms.
|
|
88
|
+
const v6 = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
89
|
+
if (v6 === "::1" ||
|
|
90
|
+
v6.startsWith("fe80:") ||
|
|
91
|
+
v6.startsWith("fc") ||
|
|
92
|
+
v6.startsWith("fd")) {
|
|
93
|
+
throw new DeliveryBlockedError("blocked_host");
|
|
94
|
+
}
|
|
95
|
+
if (isBlockedHostName(host)) {
|
|
96
|
+
throw new DeliveryBlockedError("blocked_host");
|
|
97
|
+
}
|
|
98
|
+
return parsed;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Sign and `POST` one activity to a remote inbox. A `2xx` is success; `4xx`
|
|
102
|
+
* (except `408`/`429`) is a permanent failure (drop) since the peer rejected the
|
|
103
|
+
* request; `5xx`, `408`, `429`, and network errors are retryable.
|
|
104
|
+
*/
|
|
105
|
+
export async function deliverActivity(inboxUrl, activityJson, signer, fetchImpl, now = () => Date.now(), timeoutMs = 10_000) {
|
|
106
|
+
// Throws DeliveryBlockedError for an unsafe target — the caller drops the row.
|
|
107
|
+
assertPublicHttpsTarget(inboxUrl);
|
|
108
|
+
const body = new TextEncoder().encode(activityJson);
|
|
109
|
+
const signed = await signRequest(inboxUrl, body, signer, { now });
|
|
110
|
+
let response;
|
|
111
|
+
try {
|
|
112
|
+
response = await fetchImpl(inboxUrl, {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: signed.headers,
|
|
115
|
+
body: signed.body,
|
|
116
|
+
// A hung peer must not pin the delivery worker; a timeout is retryable.
|
|
117
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return { ok: false, status: 0, retryable: true };
|
|
122
|
+
}
|
|
123
|
+
if (response.status >= 200 && response.status < 300) {
|
|
124
|
+
return { ok: true, status: response.status };
|
|
125
|
+
}
|
|
126
|
+
const retryable = response.status >= 500 ||
|
|
127
|
+
response.status === 408 ||
|
|
128
|
+
response.status === 429;
|
|
129
|
+
return { ok: false, status: response.status, retryable };
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=delivery.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"delivery.js","sourceRoot":"","sources":["../src/delivery.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
import { type ActivityPubConfig, type ActivityPubEnv } from "./config";
|
|
13
|
+
/** A `fetch`-compatible Worker handler. */
|
|
14
|
+
export type ActivityPubHandler = (request: Request, env: ActivityPubEnv, ctx: ExecutionContext) => Promise<Response>;
|
|
15
|
+
/**
|
|
16
|
+
* Create the stateless ActivityPub front-door handler. The actor document and
|
|
17
|
+
* NodeInfo are served here; all authoritative state lives in the
|
|
18
|
+
* {@link ActivityPubObject} the request is routed to.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createActivityPub(config: ActivityPubConfig): ActivityPubHandler;
|
|
21
|
+
//# sourceMappingURL=handler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAYH,OAAO,EAGL,KAAK,iBAAiB,EACtB,KAAK,cAAc,EAGpB,MAAM,UAAU,CAAC;AASlB,2CAA2C;AAC3C,MAAM,MAAM,kBAAkB,GAAG,CAC/B,OAAO,EAAE,OAAO,EAChB,GAAG,EAAE,cAAc,EACnB,GAAG,EAAE,gBAAgB,KAClB,OAAO,CAAC,QAAQ,CAAC,CAAC;AAiIvB;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,iBAAiB,GACxB,kBAAkB,CAyIpB"}
|
package/dist/handler.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
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
|
+
import { inboxLinkHeader } from "@dwk/ldn/discovery";
|
|
13
|
+
import { hostFromUrl } from "@dwk/log";
|
|
14
|
+
import { as2ContentType, buildActorDocument } from "./as2";
|
|
15
|
+
import { buildNodeInfo20, buildNodeInfo21, buildNodeInfoDiscovery, } from "./nodeinfo";
|
|
16
|
+
import { INTERNAL_HEADERS, resolveConfig, } from "./config";
|
|
17
|
+
import { ActivityPubLogEvent, ApOutcome, OUTCOME_ACTIVITY_HEADER, OUTCOME_HEADER, } from "./log";
|
|
18
|
+
import { verifyInboxSignature } from "./signature";
|
|
19
|
+
const JSON_CONTENT_TYPE = "application/json; charset=utf-8";
|
|
20
|
+
/** Fail loudly if a required Cloudflare binding is missing. */
|
|
21
|
+
function assertBindings(env) {
|
|
22
|
+
if (!env.ACTOR) {
|
|
23
|
+
throw new Error("@dwk/activitypub: missing required Durable Object binding `ACTOR`");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** The path portion of an IRI, for route matching. */
|
|
27
|
+
function pathOf(iri) {
|
|
28
|
+
return new URL(iri).pathname;
|
|
29
|
+
}
|
|
30
|
+
function jsonResponse(body, contentType, status = 200) {
|
|
31
|
+
return new Response(JSON.stringify(body), {
|
|
32
|
+
status,
|
|
33
|
+
headers: { "content-type": contentType },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function text(status, body) {
|
|
37
|
+
return new Response(body, {
|
|
38
|
+
status,
|
|
39
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/** Build the config subset the DO needs (including signing key material). */
|
|
43
|
+
function forwardedConfig(config) {
|
|
44
|
+
return {
|
|
45
|
+
iris: config.iris,
|
|
46
|
+
actorName: config.actor.name ?? config.actor.username,
|
|
47
|
+
manuallyApprovesFollowers: config.actor.manuallyApprovesFollowers ?? false,
|
|
48
|
+
pageSize: config.pageSize,
|
|
49
|
+
deliveryMaxAttempts: config.deliveryMaxAttempts,
|
|
50
|
+
deliveryBaseDelayMs: config.deliveryBaseDelayMs,
|
|
51
|
+
keyId: config.iris.keyId,
|
|
52
|
+
sharedInbox: config.sharedInbox,
|
|
53
|
+
...(config.privateKeyPem ? { privateKeyPem: config.privateKeyPem } : {}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Emit a structured event on both the logger and the metrics seam. */
|
|
57
|
+
function emit(config, level, event, fields) {
|
|
58
|
+
config.logger[level](event, fields);
|
|
59
|
+
config.metrics.count(event, fields);
|
|
60
|
+
}
|
|
61
|
+
/** Reach the per-actor Durable Object (one per actor IRI; no sharding). */
|
|
62
|
+
function actorStub(config, env) {
|
|
63
|
+
const id = env.ACTOR.idFromName(config.iris.id);
|
|
64
|
+
return env.ACTOR.get(id);
|
|
65
|
+
}
|
|
66
|
+
/** Forward a request to the DO with the internal config (and optional) headers. */
|
|
67
|
+
function forwardToDo(config, env, url, init) {
|
|
68
|
+
const headers = new Headers();
|
|
69
|
+
headers.set(INTERNAL_HEADERS.config, JSON.stringify(forwardedConfig(config)));
|
|
70
|
+
const accept = init.extra?.accept;
|
|
71
|
+
if (accept)
|
|
72
|
+
headers.set("accept", accept);
|
|
73
|
+
for (const [k, v] of Object.entries(init.extra ?? {})) {
|
|
74
|
+
if (k !== "accept")
|
|
75
|
+
headers.set(k, v);
|
|
76
|
+
}
|
|
77
|
+
const request = new Request(url, {
|
|
78
|
+
method: init.method,
|
|
79
|
+
headers,
|
|
80
|
+
...(init.body !== undefined ? { body: init.body } : {}),
|
|
81
|
+
});
|
|
82
|
+
return actorStub(config, env).fetch(request);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Translate the DO's internal inbound-outcome header into the matching log
|
|
86
|
+
* event, then strip it before the response reaches the peer.
|
|
87
|
+
*/
|
|
88
|
+
function logInboxOutcome(config, response) {
|
|
89
|
+
const outcome = response.headers.get(OUTCOME_HEADER);
|
|
90
|
+
if (!outcome)
|
|
91
|
+
return response;
|
|
92
|
+
if (outcome === ApOutcome.InboxAccepted) {
|
|
93
|
+
emit(config, "info", ActivityPubLogEvent.InboxAccepted, {
|
|
94
|
+
activity: response.headers.get(OUTCOME_ACTIVITY_HEADER) ?? undefined,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
else if (outcome === ApOutcome.InboxDuplicate) {
|
|
98
|
+
emit(config, "info", ActivityPubLogEvent.InboxDuplicate);
|
|
99
|
+
}
|
|
100
|
+
const headers = new Headers(response.headers);
|
|
101
|
+
headers.delete(OUTCOME_HEADER);
|
|
102
|
+
headers.delete(OUTCOME_ACTIVITY_HEADER);
|
|
103
|
+
return new Response(response.body, {
|
|
104
|
+
status: response.status,
|
|
105
|
+
statusText: response.statusText,
|
|
106
|
+
headers,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/** Verify an inbound `POST /inbox` signature, via the override or the built-in. */
|
|
110
|
+
async function verifySignature(config, inbox) {
|
|
111
|
+
if (config.verifyInboxSignature) {
|
|
112
|
+
return config.verifyInboxSignature(inbox);
|
|
113
|
+
}
|
|
114
|
+
return verifyInboxSignature(inbox, config.keyResolver, {
|
|
115
|
+
clockSkewSeconds: config.clockSkewSeconds,
|
|
116
|
+
now: config.now,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Create the stateless ActivityPub front-door handler. The actor document and
|
|
121
|
+
* NodeInfo are served here; all authoritative state lives in the
|
|
122
|
+
* {@link ActivityPubObject} the request is routed to.
|
|
123
|
+
*/
|
|
124
|
+
export function createActivityPub(config) {
|
|
125
|
+
const resolved = resolveConfig(config);
|
|
126
|
+
const iris = resolved.iris;
|
|
127
|
+
const actorPath = pathOf(iris.id);
|
|
128
|
+
const inboxPath = pathOf(iris.inbox);
|
|
129
|
+
const outboxPath = pathOf(iris.outbox);
|
|
130
|
+
const followersPath = pathOf(iris.followers);
|
|
131
|
+
const followingPath = pathOf(iris.following);
|
|
132
|
+
const sharedInboxPath = resolved.sharedInbox
|
|
133
|
+
? pathOf(resolved.sharedInbox)
|
|
134
|
+
: undefined;
|
|
135
|
+
const nodeInfo20Path = new URL(`${resolved.baseUrl}/nodeinfo/2.0`).pathname;
|
|
136
|
+
const nodeInfo21Path = new URL(`${resolved.baseUrl}/nodeinfo/2.1`).pathname;
|
|
137
|
+
return async (request, env, _ctx) => {
|
|
138
|
+
assertBindings(env);
|
|
139
|
+
const url = new URL(request.url);
|
|
140
|
+
const path = url.pathname;
|
|
141
|
+
const method = request.method.toUpperCase();
|
|
142
|
+
// --- NodeInfo (static discovery + mostly-static 2.1 doc) ---------------
|
|
143
|
+
if (path === "/.well-known/nodeinfo" && method === "GET") {
|
|
144
|
+
return jsonResponse(buildNodeInfoDiscovery(resolved.baseUrl), JSON_CONTENT_TYPE);
|
|
145
|
+
}
|
|
146
|
+
if ((path === nodeInfo20Path || path === nodeInfo21Path) &&
|
|
147
|
+
method === "GET") {
|
|
148
|
+
const usage = await nodeInfoUsage(resolved, env);
|
|
149
|
+
const build = path === nodeInfo20Path ? buildNodeInfo20 : buildNodeInfo21;
|
|
150
|
+
return jsonResponse(build(resolved.software, usage), JSON_CONTENT_TYPE);
|
|
151
|
+
}
|
|
152
|
+
// --- Actor document (static, served at the edge) ------------------------
|
|
153
|
+
if (path === actorPath) {
|
|
154
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
155
|
+
return text(405, "Method Not Allowed");
|
|
156
|
+
}
|
|
157
|
+
// Serve AS2 to federation peers; an HTML profile page is out of scope.
|
|
158
|
+
// A strict client may still content-negotiate for the JSON-LD profile
|
|
159
|
+
// variant (§3.2), so the response Content-Type honors `Accept`.
|
|
160
|
+
const body = JSON.stringify(buildActorDocument(iris, resolved.actor, resolved.publicKeyPem, {
|
|
161
|
+
sharedInbox: resolved.sharedInbox,
|
|
162
|
+
}));
|
|
163
|
+
return new Response(method === "HEAD" ? null : body, {
|
|
164
|
+
status: 200,
|
|
165
|
+
headers: {
|
|
166
|
+
"content-type": as2ContentType(request.headers.get("accept")),
|
|
167
|
+
// Advertise the actor's inbox via LDN discovery too, so a plain
|
|
168
|
+
// Linked Data Notifications sender (not just an ActivityPub peer) can
|
|
169
|
+
// find it from the `Link` header without parsing the AS2 body.
|
|
170
|
+
link: inboxLinkHeader(iris.inbox),
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
// --- Inbox(es): verify signature at the edge, then route to the DO ------
|
|
175
|
+
// Both the actor's personal inbox and the optional instance-level shared
|
|
176
|
+
// inbox (§7.1.3) are handled identically: the single actor is the only
|
|
177
|
+
// recipient, so a batched delivery to the shared inbox is processed for it.
|
|
178
|
+
const isPersonalInbox = path === inboxPath;
|
|
179
|
+
const isSharedInbox = sharedInboxPath !== undefined && path === sharedInboxPath;
|
|
180
|
+
if (isPersonalInbox || isSharedInbox) {
|
|
181
|
+
if (method !== "POST") {
|
|
182
|
+
// The personal inbox lets the DO answer 405; the shared inbox is
|
|
183
|
+
// write-only with no DO collection behind it, so answer here.
|
|
184
|
+
if (isSharedInbox)
|
|
185
|
+
return text(405, "Method Not Allowed");
|
|
186
|
+
return forwardToDo(resolved, env, request.url, { method });
|
|
187
|
+
}
|
|
188
|
+
const bodyBytes = new Uint8Array(await request.arrayBuffer());
|
|
189
|
+
const inbox = {
|
|
190
|
+
method,
|
|
191
|
+
path: `${url.pathname}${url.search}`,
|
|
192
|
+
headers: request.headers,
|
|
193
|
+
body: bodyBytes,
|
|
194
|
+
};
|
|
195
|
+
const result = await verifySignature(resolved, inbox);
|
|
196
|
+
if (!result.ok) {
|
|
197
|
+
emit(resolved, "warn", ActivityPubLogEvent.SignatureRejected, {
|
|
198
|
+
reason: result.reason,
|
|
199
|
+
});
|
|
200
|
+
return text(401, `invalid_signature: ${result.reason}`);
|
|
201
|
+
}
|
|
202
|
+
emit(resolved, "info", ActivityPubLogEvent.SignatureAccepted, {
|
|
203
|
+
actorHost: hostFromUrl(result.actor),
|
|
204
|
+
});
|
|
205
|
+
const response = await forwardToDo(resolved, env, request.url, {
|
|
206
|
+
method,
|
|
207
|
+
body: bodyBytes,
|
|
208
|
+
extra: { [INTERNAL_HEADERS.signedActor]: result.actor },
|
|
209
|
+
});
|
|
210
|
+
return logInboxOutcome(resolved, response);
|
|
211
|
+
}
|
|
212
|
+
// --- Owner publish endpoint (the micropub → Create fan-out seam) --------
|
|
213
|
+
if (path === outboxPath && method === "POST") {
|
|
214
|
+
if (!resolved.publishToken) {
|
|
215
|
+
emit(resolved, "warn", ActivityPubLogEvent.PublishRejected, {
|
|
216
|
+
reason: "disabled",
|
|
217
|
+
});
|
|
218
|
+
return text(404, "Not Found");
|
|
219
|
+
}
|
|
220
|
+
if (!(await authorizedPublish(request, resolved.publishToken))) {
|
|
221
|
+
emit(resolved, "warn", ActivityPubLogEvent.PublishRejected, {
|
|
222
|
+
reason: "unauthorized",
|
|
223
|
+
});
|
|
224
|
+
return text(401, "Unauthorized");
|
|
225
|
+
}
|
|
226
|
+
const body = new Uint8Array(await request.arrayBuffer());
|
|
227
|
+
return forwardToDo(resolved, env, request.url, {
|
|
228
|
+
method,
|
|
229
|
+
body: body,
|
|
230
|
+
extra: { [INTERNAL_HEADERS.publish]: "1" },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
// --- Collection reads (authoritative; routed to the DO) -----------------
|
|
234
|
+
if (method === "GET" &&
|
|
235
|
+
(path === outboxPath || path === followersPath || path === followingPath)) {
|
|
236
|
+
return forwardToDo(resolved, env, request.url, { method });
|
|
237
|
+
}
|
|
238
|
+
if (path === inboxPath || path === outboxPath) {
|
|
239
|
+
// Non-GET/POST on a collection: let the DO answer 405.
|
|
240
|
+
return forwardToDo(resolved, env, request.url, { method });
|
|
241
|
+
}
|
|
242
|
+
return text(404, "Not Found");
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/** Whether a publish request carries the configured bearer token. */
|
|
246
|
+
async function authorizedPublish(request, token) {
|
|
247
|
+
const header = request.headers.get("authorization");
|
|
248
|
+
if (!header)
|
|
249
|
+
return false;
|
|
250
|
+
const match = /^Bearer\s+(.+)$/i.exec(header);
|
|
251
|
+
if (match === null)
|
|
252
|
+
return false;
|
|
253
|
+
return constantTimeEqual(match[1], token);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Constant-time string comparison that leaks neither the content **nor the
|
|
257
|
+
* length** of the secret. Both inputs are hashed to fixed-length SHA-256
|
|
258
|
+
* digests (via WebCrypto, so the package needs no `node:crypto` /
|
|
259
|
+
* `nodejs_compat`) and the digests are compared without an early return.
|
|
260
|
+
*/
|
|
261
|
+
async function constantTimeEqual(a, b) {
|
|
262
|
+
const enc = new TextEncoder();
|
|
263
|
+
const [ha, hb] = await Promise.all([
|
|
264
|
+
crypto.subtle.digest("SHA-256", enc.encode(a)),
|
|
265
|
+
crypto.subtle.digest("SHA-256", enc.encode(b)),
|
|
266
|
+
]);
|
|
267
|
+
const va = new Uint8Array(ha);
|
|
268
|
+
const vb = new Uint8Array(hb);
|
|
269
|
+
let diff = 0;
|
|
270
|
+
for (let i = 0; i < va.length; i++)
|
|
271
|
+
diff |= va[i] ^ vb[i];
|
|
272
|
+
return diff === 0;
|
|
273
|
+
}
|
|
274
|
+
/** Fetch live usage counts from the DO for the NodeInfo document. */
|
|
275
|
+
async function nodeInfoUsage(config, env) {
|
|
276
|
+
try {
|
|
277
|
+
const statsUrl = `${config.iris.id}/__stats`;
|
|
278
|
+
const response = await forwardToDo(config, env, statsUrl, {
|
|
279
|
+
method: "GET",
|
|
280
|
+
});
|
|
281
|
+
if (!response.ok)
|
|
282
|
+
return {};
|
|
283
|
+
const stats = (await response.json());
|
|
284
|
+
return {
|
|
285
|
+
users: typeof stats.users === "number" ? stats.users : 1,
|
|
286
|
+
localPosts: typeof stats.localPosts === "number" ? stats.localPosts : 0,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return {};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
//# sourceMappingURL=handler.js.map
|