From 00fdc69b0efca2c721fddd83b80a106704b7266b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 03:54:25 +0000 Subject: [PATCH 1/4] feat(dpop): accept EdDSA (Ed25519) and ES512 proof algorithms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widen the allow-list the spec marked "not implemented yet — widen on demand": ES512 (P-521 + SHA-512) and EdDSA over RFC 8037 OKP keys, including the OKP RFC 7638 thumbprint. Ed448 stays rejected as crv_mismatch — the Workers runtime has no Web Crypto support for it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XT45PcE3pgXgsQffQDWdhW --- .changeset/dpop-eddsa-es512.md | 5 +++ packages/dpop/README.md | 7 ++-- packages/dpop/src/index.test.ts | 65 +++++++++++++++++++++++++++++++++ packages/dpop/src/index.ts | 53 +++++++++++++++++++++------ spec/packages/dpop.md | 11 +++--- 5 files changed, 121 insertions(+), 20 deletions(-) create mode 100644 .changeset/dpop-eddsa-es512.md diff --git a/.changeset/dpop-eddsa-es512.md b/.changeset/dpop-eddsa-es512.md new file mode 100644 index 00000000..a6102f9b --- /dev/null +++ b/.changeset/dpop-eddsa-es512.md @@ -0,0 +1,5 @@ +--- +"@dwk/dpop": minor +--- + +Widen the DPoP proof algorithm allow-list with `EdDSA` (Ed25519, RFC 8037 OKP keys — including the OKP RFC 7638 thumbprint) and `ES512` (P-521 + SHA-512). The spec had marked both "not implemented yet — widen on demand"; Ed25519 in particular is where fediverse client signing is converging. Ed448 stays rejected (`crv_mismatch`) — the Workers runtime has no Web Crypto support for it. Symmetric algorithms and `none` remain excluded. diff --git a/packages/dpop/README.md b/packages/dpop/README.md index 36afc5e7..1af89e3a 100644 --- a/packages/dpop/README.md +++ b/packages/dpop/README.md @@ -58,9 +58,10 @@ rejected with `jkt_required` rather than validating an unbound proof - **Header** — `typ` is exactly `dpop+jwt`; no `crit` parameter is present (RFC 7515 §4.1.11); `alg` is an asymmetric algorithm from the allow-list - (`ES256`, `ES384`, `RS256`, `PS256` — never `none` or HMAC); `jwk` is present, - carries no private key material, has an EC `crv` matching the `alg`, and (for - RSA) a modulus of at least 2048 bits. + (`ES256`, `ES384`, `ES512`, `EdDSA`, `RS256`, `PS256` — never `none` or + HMAC); `jwk` is present, carries no private key material, has an EC/OKP + `crv` matching the `alg` (`EdDSA` is Ed25519 only — the Workers runtime has + no Ed448 Web Crypto support), and (for RSA) a modulus of at least 2048 bits. - **Signature** — over `header.payload` using the embedded `jwk`. - **Claims** — `htm` matches the request method (case-insensitive); `htu` matches the request URI after normalization (scheme/host lowercased, default diff --git a/packages/dpop/src/index.test.ts b/packages/dpop/src/index.test.ts index bf90729f..797a30f7 100644 --- a/packages/dpop/src/index.test.ts +++ b/packages/dpop/src/index.test.ts @@ -52,6 +52,16 @@ const SIGNERS: Record = { sign: { name: "ECDSA", hash: "SHA-256" }, alg: "ES256", }, + ES512: { + generate: { name: "ECDSA", namedCurve: "P-521" }, + sign: { name: "ECDSA", hash: "SHA-512" }, + alg: "ES512", + }, + EdDSA: { + generate: { name: "Ed25519" }, + sign: { name: "Ed25519" }, + alg: "EdDSA", + }, RS256: { generate: { name: "RSASSA-PKCS1-v1_5", @@ -133,10 +143,14 @@ async function makeProof( } let es256: KeyMaterial; +let es512: KeyMaterial; +let eddsa: KeyMaterial; let rs256: KeyMaterial; beforeAll(async () => { es256 = await makeKey("ES256"); + es512 = await makeKey("ES512"); + eddsa = await makeKey("EdDSA"); rs256 = await makeKey("RS256"); }); @@ -159,6 +173,33 @@ describe("verifyDpopProof — happy path", () => { expect(result.jkt).toMatch(/^[A-Za-z0-9_-]+$/); }); + it("verifies a valid ES512 proof (P-521 + SHA-512 path)", async () => { + const proof = await makeProof(es512); + const result = await verifyDpopProof({ ...base(), proof }); + expect(result.valid).toBe(true); + expect(result.jkt).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it("verifies a valid EdDSA proof (RFC 8037 OKP thumbprint + Ed25519 path)", async () => { + const proof = await makeProof(eddsa); + const result = await verifyDpopProof({ ...base(), proof }); + expect(result.valid).toBe(true); + expect(result.jti).toBe("unique-id-123"); + expect(result.jkt).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it("computes a stable OKP jkt for the same Ed25519 key", async () => { + const a = await verifyDpopProof({ + ...base(), + proof: await makeProof(eddsa), + }); + const b = await verifyDpopProof({ + ...base(), + proof: await makeProof(eddsa), + }); + expect(a.jkt).toBe(b.jkt); + }); + it("matches htm case-insensitively and normalizes htu", async () => { const proof = await makeProof(es256, { payload: { htu: "https://POD.example:443/resource?q=1#frag" }, @@ -267,6 +308,30 @@ describe("verifyDpopProof — header checks", () => { expect(result).toMatchObject({ valid: false, reason: "rsa_key_too_small" }); }); + it("rejects an OKP jwk whose crv is not Ed25519 (Ed448 unsupported)", async () => { + const proof = await makeProof(eddsa, { + header: { jwk: { ...eddsa.publicJwk, crv: "Ed448" } }, + }); + const result = await verifyDpopProof({ ...base(), proof }); + expect(result).toMatchObject({ valid: false, reason: "crv_mismatch" }); + }); + + it("rejects an OKP jwk carrying private key material (RFC 8037 d)", async () => { + const proof = await makeProof(eddsa, { + header: { jwk: { ...eddsa.publicJwk, d: "private-scalar" } }, + }); + const result = await verifyDpopProof({ ...base(), proof }); + expect(result).toMatchObject({ valid: false, reason: "jwk_private" }); + }); + + it("treats a missing OKP x as jwk_invalid, not crv_mismatch", async () => { + const proof = await makeProof(eddsa, { + header: { jwk: { ...eddsa.publicJwk, x: undefined } }, + }); + const result = await verifyDpopProof({ ...base(), proof }); + expect(result).toMatchObject({ valid: false, reason: "jwk_invalid" }); + }); + it("treats a missing EC crv as jwk_invalid, not crv_mismatch", async () => { const proof = await makeProof(es256, { header: { jwk: { ...es256.publicJwk, crv: undefined } }, diff --git a/packages/dpop/src/index.ts b/packages/dpop/src/index.ts index ad3c9bc6..d2168d93 100644 --- a/packages/dpop/src/index.ts +++ b/packages/dpop/src/index.ts @@ -23,11 +23,11 @@ export const DEFAULT_MAX_AGE_SECONDS = 300; * * Symmetric (`HS*`) and `none` are deliberately excluded: a DPoP proof must be * signed by the client-held private key whose public half is embedded as `jwk`. - * `EdDSA`/`ES512` are simply not implemented yet — no deliberate security - * reason excludes them, unlike the symmetric/`none` exclusion above; widen - * this allow-list if a caller needs one of them. + * `EdDSA` is Ed25519 only — Ed448 has no Web Crypto support in the Workers + * runtime, so an Ed448 `jwk` is rejected as `crv_mismatch`. */ -export type DpopAlgorithm = "ES256" | "ES384" | "RS256" | "PS256"; +export type DpopAlgorithm = + "ES256" | "ES384" | "ES512" | "EdDSA" | "RS256" | "PS256"; /** Stable, locale-independent failure codes returned in {@link DpopVerifyResult.reason}. */ export type DpopFailureReason = @@ -130,8 +130,8 @@ interface VerifyAlg { } interface AlgSpec { - kty: "EC" | "RSA"; - /** For EC algorithms, the curve the `alg` implies (`jwk.crv` must match it). */ + kty: "EC" | "RSA" | "OKP"; + /** For EC/OKP algorithms, the curve the `alg` implies (`jwk.crv` must match it). */ expectedCrv?: string; importParams: ImportAlg; verifyParams: VerifyAlg; @@ -150,6 +150,20 @@ const ALGS: Record = { importParams: { name: "ECDSA", namedCurve: "P-384" }, verifyParams: { name: "ECDSA", hash: "SHA-384" }, }, + ES512: { + kty: "EC", + expectedCrv: "P-521", + importParams: { name: "ECDSA", namedCurve: "P-521" }, + verifyParams: { name: "ECDSA", hash: "SHA-512" }, + }, + // RFC 8037: EdDSA proofs carry an OKP jwk. Ed25519 only — Ed448 has no Web + // Crypto support in the Workers runtime, so its jwk fails the crv check. + EdDSA: { + kty: "OKP", + expectedCrv: "Ed25519", + importParams: { name: "Ed25519" }, + verifyParams: { name: "Ed25519" }, + }, RS256: { kty: "RSA", importParams: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, @@ -282,6 +296,13 @@ async function jwkThumbprint( return null; } canonical = JSON.stringify({ e, kty: "RSA", n }); + } else if (jwk.kty === "OKP") { + // RFC 8037 §2: an OKP key's thumbprint members are crv, kty, x. + const { crv, x } = jwk; + if (typeof crv !== "string" || typeof x !== "string") { + return null; + } + canonical = JSON.stringify({ crv, kty: "OKP", x }); } else { return null; } @@ -291,7 +312,7 @@ async function jwkThumbprint( /** Build a clean public-only JWK for `importKey`, dropping `alg`/`use`/`key_ops`. */ function publicJwk( jwk: Record, - kty: "EC" | "RSA", + kty: "EC" | "RSA" | "OKP", ): JsonWebKey | null { if (kty === "EC") { const { crv, x, y } = jwk; @@ -304,6 +325,13 @@ function publicJwk( } return { kty: "EC", crv, x, y }; } + if (kty === "OKP") { + const { crv, x } = jwk; + if (typeof crv !== "string" || typeof x !== "string") { + return null; + } + return { kty: "OKP", crv, x }; + } const { n, e } = jwk; if (typeof n !== "string" || typeof e !== "string") { return null; @@ -385,12 +413,13 @@ export async function verifyDpopProof( if (jwk.kty !== algSpec.kty) { return fail("jwk_invalid"); } - // EC: the curve must be the one the alg implies (ES256⇒P-256, …). WebCrypto - // would also reject a mismatch on import, but check it explicitly up front. - // A missing/non-string `crv` is malformed, not a mismatch — let it fall - // through to `publicJwk` below, which rejects it as `jwk_invalid`. + // EC/OKP: the curve must be the one the alg implies (ES256⇒P-256, + // EdDSA⇒Ed25519, …). WebCrypto would also reject a mismatch on import, but + // check it explicitly up front. A missing/non-string `crv` is malformed, not + // a mismatch — let it fall through to `publicJwk` below, which rejects it as + // `jwk_invalid`. if ( - algSpec.kty === "EC" && + algSpec.expectedCrv !== undefined && typeof jwk.crv === "string" && jwk.crv !== algSpec.expectedCrv ) { diff --git a/spec/packages/dpop.md b/spec/packages/dpop.md index 8ae42e18..febf2557 100644 --- a/spec/packages/dpop.md +++ b/spec/packages/dpop.md @@ -31,11 +31,12 @@ future `@dwk` packages can adopt it unchanged. **without a Workers runtime**. - **Protocol-agnostic:** no IndieWeb- or Solid-specific claim handling baked in. Caller supplies issuer/audience expectations. -- **Algorithm allow-list:** `DpopAlgorithm` is `ES256 | ES384 | RS256 | PS256`. - Symmetric (`HS*`) and `none` are excluded on purpose — a DPoP proof must be - signed by the client-held private key whose public half is the embedded - `jwk`. `EdDSA`/`ES512` are simply not implemented yet (no deliberate - security reason excludes them); widen the allow-list if a caller needs one. +- **Algorithm allow-list:** `DpopAlgorithm` is + `ES256 | ES384 | ES512 | EdDSA | RS256 | PS256`. Symmetric (`HS*`) and + `none` are excluded on purpose — a DPoP proof must be signed by the + client-held private key whose public half is the embedded `jwk`. `EdDSA` + accepts Ed25519 (RFC 8037 OKP keys) only: Ed448 has no Web Crypto support + in the Workers runtime, so an Ed448 `jwk` is rejected as `crv_mismatch`. - **`htu` has no port allow-list.** `htu` binding is exact-match string comparison after normalization (scheme + host + path, port included when non-default); the package does not restrict which ports a caller's From 83ea06ae4d077a8f65022bf9aea242055c36dd24 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 03:54:26 +0000 Subject: [PATCH 2/4] feat: back-fill specificationURL onto every catalog route claim Anglesite-app's consumer requires the field on every prefix claim (only a specification can approve child paths) and recommends it on exact claims; spec/catalog.md had tracked the back-fill as a follow-up. Every claim now carries it except webdav's /dav-credentials, a package-defined exact-claim admin endpoint with no governing external spec. The catalog gate now enforces the prefix-claim requirement mechanically. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XT45PcE3pgXgsQffQDWdhW --- catalog.json | 81 +++++++++++++++++++++++------------ scripts/catalog-gate.mjs | 7 +++ scripts/catalog-gate.test.mjs | 18 ++++++++ spec/catalog.md | 10 +++-- 4 files changed, 85 insertions(+), 31 deletions(-) diff --git a/catalog.json b/catalog.json index f1c3baf4..863dc335 100644 --- a/catalog.json +++ b/catalog.json @@ -90,13 +90,15 @@ "path": "/micropub", "match": "exact", "methods": ["GET", "POST"], - "handler": "createMicropub" + "handler": "createMicropub", + "specificationURL": "https://micropub.spec.indieweb.org/" }, { "path": "/media", "match": "exact", "methods": ["POST"], - "handler": "createMicropub" + "handler": "createMicropub", + "specificationURL": "https://micropub.spec.indieweb.org/#media-endpoint" }, { "path": "/media/", @@ -142,7 +144,8 @@ "path": "/microsub", "match": "exact", "methods": ["GET", "POST"], - "handler": "createMicrosub" + "handler": "createMicrosub", + "specificationURL": "https://indieweb.org/Microsub-spec" } ] }, @@ -172,7 +175,8 @@ "path": "/webmention", "match": "exact", "methods": ["POST"], - "handler": "createWebmention" + "handler": "createWebmention", + "specificationURL": "https://www.w3.org/TR/webmention/" } ] }, @@ -201,7 +205,8 @@ "path": "/websub", "match": "exact", "methods": ["POST"], - "handler": "createWebSub" + "handler": "createWebSub", + "specificationURL": "https://www.w3.org/TR/websub/" } ] }, @@ -228,13 +233,15 @@ "match": "prefix", "methods": ["GET", "POST"], "head": true, - "handler": "createActivityPub" + "handler": "createActivityPub", + "specificationURL": "https://www.w3.org/TR/activitypub/" }, { "path": "/inbox", "match": "exact", "methods": ["POST"], - "handler": "createActivityPub" + "handler": "createActivityPub", + "specificationURL": "https://www.w3.org/TR/activitypub/" }, { "path": "/.well-known/nodeinfo", @@ -242,14 +249,16 @@ "methods": ["GET"], "head": true, "handler": "createActivityPub", - "authorityBound": true + "authorityBound": true, + "specificationURL": "https://nodeinfo.diaspora.software/protocol.html" }, { "path": "/nodeinfo/", "match": "prefix", "methods": ["GET"], "head": true, - "handler": "createActivityPub" + "handler": "createActivityPub", + "specificationURL": "https://nodeinfo.diaspora.software/protocol.html" } ] }, @@ -295,20 +304,23 @@ "match": "exact", "methods": ["GET"], "handler": "createMastodonApi", - "authorityBinding": true + "authorityBinding": true, + "specificationURL": "https://docs.joinmastodon.org/spec/oauth/" }, { "path": "/oauth/token", "match": "exact", "methods": ["POST"], "handler": "createMastodonApi", - "authorityBinding": true + "authorityBinding": true, + "specificationURL": "https://docs.joinmastodon.org/spec/oauth/" }, { "path": "/oauth/revoke", "match": "exact", "methods": ["POST"], - "handler": "createMastodonApi" + "handler": "createMastodonApi", + "specificationURL": "https://docs.joinmastodon.org/spec/oauth/" } ] }, @@ -329,7 +341,8 @@ "methods": ["GET"], "head": true, "handler": "createWebfinger", - "authorityBound": true + "authorityBound": true, + "specificationURL": "https://www.rfc-editor.org/rfc/rfc7033" } ] }, @@ -350,7 +363,8 @@ "methods": ["GET"], "head": true, "handler": "createHostMeta", - "authorityBound": true + "authorityBound": true, + "specificationURL": "https://www.rfc-editor.org/rfc/rfc6415" }, { "path": "/.well-known/host-meta.json", @@ -358,7 +372,8 @@ "methods": ["GET"], "head": true, "handler": "createHostMeta", - "authorityBound": true + "authorityBound": true, + "specificationURL": "https://www.rfc-editor.org/rfc/rfc6415" } ] }, @@ -384,25 +399,29 @@ "path": "/webauthn/register/options", "match": "exact", "methods": ["POST"], - "handler": "createWebAuthn" + "handler": "createWebAuthn", + "specificationURL": "https://www.w3.org/TR/webauthn-3/" }, { "path": "/webauthn/register/verify", "match": "exact", "methods": ["POST"], - "handler": "createWebAuthn" + "handler": "createWebAuthn", + "specificationURL": "https://www.w3.org/TR/webauthn-3/" }, { "path": "/webauthn/authenticate/options", "match": "exact", "methods": ["POST"], - "handler": "createWebAuthn" + "handler": "createWebAuthn", + "specificationURL": "https://www.w3.org/TR/webauthn-3/" }, { "path": "/webauthn/authenticate/verify", "match": "exact", "methods": ["POST"], - "handler": "createWebAuthn" + "handler": "createWebAuthn", + "specificationURL": "https://www.w3.org/TR/webauthn-3/" } ] }, @@ -459,14 +478,16 @@ "match": "exact", "methods": ["GET", "PUT", "POST", "PATCH", "DELETE", "OPTIONS"], "head": true, - "handler": "createSolidPod" + "handler": "createSolidPod", + "specificationURL": "https://solidproject.org/TR/protocol" }, { "path": "/pod/", "match": "prefix", "methods": ["GET", "PUT", "POST", "PATCH", "DELETE", "OPTIONS"], "head": true, - "handler": "createSolidPod" + "handler": "createSolidPod", + "specificationURL": "https://solidproject.org/TR/protocol" } ], "triggers": [ @@ -523,7 +544,8 @@ "OPTIONS" ], "head": true, - "handler": "createSolidPodWebdav" + "handler": "createSolidPodWebdav", + "specificationURL": "https://www.rfc-editor.org/rfc/rfc4918" }, { "path": "/dav/", @@ -542,7 +564,8 @@ "OPTIONS" ], "head": true, - "handler": "createSolidPodWebdav" + "handler": "createSolidPodWebdav", + "specificationURL": "https://www.rfc-editor.org/rfc/rfc4918" }, { "path": "/dav-credentials", @@ -584,7 +607,8 @@ "match": "prefix", "methods": ["GET", "PUT", "DELETE", "OPTIONS"], "head": true, - "handler": "createRemoteStorage" + "handler": "createRemoteStorage", + "specificationURL": "https://datatracker.ietf.org/doc/html/draft-dejong-remotestorage" } ], "triggers": [ @@ -623,7 +647,8 @@ "path": "/xrpc/", "match": "prefix", "methods": ["GET", "POST"], - "handler": "createAtprotoPds" + "handler": "createAtprotoPds", + "specificationURL": "https://atproto.com/specs/xrpc" }, { "path": "/.well-known/atproto-did", @@ -631,7 +656,8 @@ "methods": ["GET"], "head": true, "handler": "createAtprotoPds", - "authorityBound": true + "authorityBound": true, + "specificationURL": "https://atproto.com/specs/handle" }, { "path": "/.well-known/did.json", @@ -639,7 +665,8 @@ "methods": ["GET"], "head": true, "handler": "createAtprotoPds", - "authorityBound": true + "authorityBound": true, + "specificationURL": "https://w3c-ccg.github.io/did-method-web/" } ] } diff --git a/scripts/catalog-gate.mjs b/scripts/catalog-gate.mjs index ab7e3414..54169b5e 100644 --- a/scripts/catalog-gate.mjs +++ b/scripts/catalog-gate.mjs @@ -336,6 +336,13 @@ function checkRoutes(entry, label, violations) { ); } } + } else if (route.match === "prefix") { + // Anglesite-app's consumer (WorkerRouteClaims.validate) requires a + // governing specification on every prefix claim — only a specification + // can approve child paths (spec/catalog.md, Anglesite-app#829). + violations.push( + `${where}: a prefix claim requires a "specificationURL" (only a specification can approve child paths).`, + ); } const key = `${route.match} ${route.path}`; diff --git a/scripts/catalog-gate.test.mjs b/scripts/catalog-gate.test.mjs index c412d605..04f17b53 100644 --- a/scripts/catalog-gate.test.mjs +++ b/scripts/catalog-gate.test.mjs @@ -230,6 +230,7 @@ function routedFixture() { methods: ["GET", "POST", "PUT", "DELETE", "PATCH"], head: true, handler: "createSolidPod", + specificationURL: "https://solidproject.org/TR/protocol", }, ]; return input; @@ -285,6 +286,21 @@ test("an unknown match kind is rejected", () => { assert.match(violations[0], /match/); }); +test("a prefix claim without a specificationURL is rejected", () => { + const input = routedFixture(); + delete input.catalog.workers[1].routes[0].specificationURL; + const violations = evaluateCatalog(input); + assert.equal(violations.length, 1); + assert.match(violations[0], /prefix claim requires a "specificationURL"/); +}); + +test("an exact claim without a specificationURL still passes", () => { + const input = routedFixture(); + // workers[0]'s /webmention exact claim carries none — recommended, not + // required, on exact claims. + assert.deepEqual(evaluateCatalog(input), []); +}); + test("a prefix route path must end with a slash", () => { const input = routedFixture(); input.catalog.workers[1].routes[0].path = "/pod"; @@ -410,6 +426,7 @@ test("nested prefixes across entries collide", () => { match: "prefix", methods: ["GET"], handler: "createWebmention", + specificationURL: "https://www.w3.org/TR/webmention/", }); const violations = evaluateCatalog(input); assert.equal(violations.length, 1); @@ -532,6 +549,7 @@ test("overlapping claims within the same entry are allowed", () => { methods: ["GET"], head: true, handler: "createWebmention", + specificationURL: "https://micropub.spec.indieweb.org/#media-endpoint", }, ); assert.deepEqual(evaluateCatalog(input), []); diff --git a/spec/catalog.md b/spec/catalog.md index 8d2f0f07..e60aa600 100644 --- a/spec/catalog.md +++ b/spec/catalog.md @@ -168,10 +168,12 @@ unrecognized JSON keys, so adding them is backward-compatible. them elsewhere). Defaults to `false` when omitted. - `specificationURL` — the governing protocol specification. Anglesite-app's consumer requires this on every `prefix` claim (only a specification can - approve child paths); recommended on `exact` claims too. Not yet - back-filled onto this repo's pre-existing `prefix` claims (`/users/`, - `/nodeinfo/`, `/pod/`, `/dav/`, `/storage/`, `/xrpc/`) — tracked as a - follow-up rather than bundled into an unrelated change. + approve child paths); recommended on `exact` claims too. Back-filled onto + every claim in `catalog.json`, and the catalog gate now enforces the + prefix-claim requirement mechanically. One deliberate exception: + `webdav`'s `/dav-credentials` is a package-defined admin endpoint with no + governing external specification, and as an `exact` claim the field is + optional there. **Mount-prefix contract.** The composition contract lets a composer mount any handler under an arbitrary path prefix. Route claims are static data, so they From 921121bf8a891e241782f509f7a7c827caf2bef8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 03:54:26 +0000 Subject: [PATCH 3/4] =?UTF-8?q?feat(webmention):=20re-send=20webmentions?= =?UTF-8?q?=20for=20a=20deleted=20source=20(=C2=A73.1.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec's last known gap, a SHOULD: SendOptions gains an opt-in sentLog recording every accepted notification (createD1SentLog — own webmentions_sent table, strongly consistent), and resendForDeletedSource(source) re-sends to every recorded target once the source serves 410 Gone so receivers re-verify and drop the mention. Accepted (or endpoint-less) re-sends clear their log row; failed ones keep it for a later retry. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XT45PcE3pgXgsQffQDWdhW --- .../webmention-deleted-source-resend.md | 5 + packages/webmention/README.md | 28 ++++ packages/webmention/src/index.ts | 7 + packages/webmention/src/log.ts | 10 ++ packages/webmention/src/sender.test.ts | 149 +++++++++++++++++- packages/webmention/src/sender.ts | 78 ++++++++- packages/webmention/src/sent-log.test.ts | 54 +++++++ packages/webmention/src/sent-log.ts | 100 ++++++++++++ spec/packages/webmention.md | 22 ++- 9 files changed, 445 insertions(+), 8 deletions(-) create mode 100644 .changeset/webmention-deleted-source-resend.md create mode 100644 packages/webmention/src/sent-log.test.ts create mode 100644 packages/webmention/src/sent-log.ts diff --git a/.changeset/webmention-deleted-source-resend.md b/.changeset/webmention-deleted-source-resend.md new file mode 100644 index 00000000..0ed21898 --- /dev/null +++ b/.changeset/webmention-deleted-source-resend.md @@ -0,0 +1,5 @@ +--- +"@dwk/webmention": minor +--- + +Implement the Webmention §3.1.5 deleted-source re-send (the spec's last known gap, a SHOULD): `SendOptions` gains an opt-in `sentLog` that records every accepted notification (`createD1SentLog` — D1-backed, own `webmentions_sent` table, strongly consistent), and the new `resendForDeletedSource(source, options)` re-sends to every recorded target once the source serves `410 Gone`, so receivers re-verify and drop the mention. Accepted (or endpoint-less) re-sends clear their log row; failed ones keep it for a later retry. Two new observability events: `webmention.send.sent_log_write_failed` and `webmention.resend.completed`. diff --git a/packages/webmention/README.md b/packages/webmention/README.md index d54cbed3..d874ba4a 100644 --- a/packages/webmention/README.md +++ b/packages/webmention/README.md @@ -66,6 +66,34 @@ relative URLs resolved against the (post-redirect) document URL — honoring a `http://webmention.org/` rel is also accepted. The sender refuses to POST a discovered endpoint that is not `http(s)`. +### Re-sending after a delete (§3.1.5) + +When a published page is later deleted, the spec asks the publisher to +re-send its Webmentions so each receiver re-verifies, sees the `410 Gone`, +and drops the stored mention. That requires remembering who was notified — +opt in by passing a sent log: + +```ts +import { + createD1SentLog, + sendWebmentions, + resendForDeletedSource, +} from "@dwk/webmention"; + +const sentLog = createD1SentLog(env.WEBMENTION_INBOX); // own table: webmentions_sent + +// On publish — accepted notifications are recorded: +await sendWebmentions(myPostUrl, outboundLinks, { sentLog }); + +// After the page is deleted and serving 410 Gone: +await resendForDeletedSource(myPostUrl, { sentLog }); +``` + +Re-send the mentions only once the deletion is live — a receiver re-verifying +against a still-`200` source keeps the mention. Rows are cleared for targets +that accept the re-send (or no longer declare an endpoint); failed targets +keep their row so a later call retries them. + ## Bindings (`Env` fragment) | Binding | Type | Required | Purpose | diff --git a/packages/webmention/src/index.ts b/packages/webmention/src/index.ts index f53dbb37..24cf97a5 100644 --- a/packages/webmention/src/index.ts +++ b/packages/webmention/src/index.ts @@ -47,9 +47,16 @@ export { export { sendWebmention, sendWebmentions, + resendForDeletedSource, type SendOptions, type SendResult, + type ResendOptions, } from "./sender.js"; +export { + createD1SentLog, + type SentLog, + type D1SentLogOptions, +} from "./sent-log.js"; export { verifySource, sourceLinksTo, diff --git a/packages/webmention/src/log.ts b/packages/webmention/src/log.ts index c840ed5e..ee8ade47 100644 --- a/packages/webmention/src/log.ts +++ b/packages/webmention/src/log.ts @@ -37,6 +37,16 @@ export const WebmentionLogEvent = { QueueRetry: "webmention.queue.retry", /** A send attempt finished. Fields: `endpointHost`, `delivered`, `status`. */ SendCompleted: "webmention.send.completed", + /** + * A delivered notification could not be recorded in the sent log; the send + * itself succeeded. Field: `targetHost`. + */ + SentLogWriteFailed: "webmention.send.sent_log_write_failed", + /** + * A §3.1.5 deleted-source re-send pass finished. Fields: `targets`, + * `delivered` (count re-accepted). + */ + ResendCompleted: "webmention.resend.completed", } as const; /** Union of the event-name string literals in {@link WebmentionLogEvent}. */ diff --git a/packages/webmention/src/sender.test.ts b/packages/webmention/src/sender.test.ts index 7896947d..3cbd6177 100644 --- a/packages/webmention/src/sender.test.ts +++ b/packages/webmention/src/sender.test.ts @@ -1,10 +1,50 @@ import { describe, it, expect, vi } from "vitest"; -import { sendWebmention, sendWebmentions } from "./sender.js"; +import { + sendWebmention, + sendWebmentions, + resendForDeletedSource, +} from "./sender.js"; +import type { SentLog } from "./sent-log.js"; import type { FetchLike } from "@dwk/safe-fetch"; const source = "https://me.example/post"; const target = "https://target.example/article"; +/** In-memory {@link SentLog} for exercising the sender's recording paths. */ +function memorySentLog(): SentLog & { + readonly rows: Map; +} { + const rows = new Map(); + return { + rows, + async record(src, tgt, sentAt) { + rows.set(`${src}\n${tgt}`, sentAt); + }, + async listTargets(src) { + return [...rows.keys()] + .filter((key) => key.startsWith(`${src}\n`)) + .map((key) => key.slice(src.length + 1)); + }, + async remove(src, tgt) { + rows.delete(`${src}\n${tgt}`); + }, + }; +} + +/** Fetch fake: `rel=webmention` Link header on GET, `status` on POST. */ +function endpointFetch(status: number): FetchLike { + return vi.fn(async (url, init) => + init?.method === "POST" + ? new Response(null, { status }) + : new Response("", { + headers: { + link: `<${new URL("/wm", url).href}>; rel="webmention"`, + "content-type": "text/html", + }, + }), + ); +} + describe("sendWebmention", () => { it("discovers the endpoint then POSTs source and target", async () => { const calls: { url: string; init?: RequestInit }[] = []; @@ -90,6 +130,113 @@ describe("sendWebmention", () => { }); }); +describe("sendWebmention — sent log", () => { + it("records a delivered notification when a sentLog is supplied", async () => { + const sentLog = memorySentLog(); + await sendWebmention(source, target, { + fetch: endpointFetch(202), + sentLog, + }); + expect(await sentLog.listTargets(source)).toEqual([target]); + }); + + it("does not record a rejected or endpoint-less notification", async () => { + const sentLog = memorySentLog(); + await sendWebmention(source, target, { + fetch: endpointFetch(400), + sentLog, + }); + const noEndpoint: FetchLike = vi.fn( + async () => + new Response("

none

", { + headers: { "content-type": "text/html" }, + }), + ); + await sendWebmention(source, "https://b.example/", { + fetch: noEndpoint, + sentLog, + }); + expect(await sentLog.listTargets(source)).toEqual([]); + }); + + it("survives a sentLog write failure — the send still reports delivered", async () => { + const sentLog = memorySentLog(); + sentLog.record = async () => { + throw new Error("d1 down"); + }; + const result = await sendWebmention(source, target, { + fetch: endpointFetch(202), + sentLog, + }); + expect(result.delivered).toBe(true); + }); +}); + +describe("resendForDeletedSource", () => { + it("re-sends to every recorded target and clears accepted rows", async () => { + const sentLog = memorySentLog(); + await sentLog.record(source, "https://a.example/", 1); + await sentLog.record(source, "https://b.example/", 2); + // Another source's row must be untouched by the resend. + await sentLog.record("https://me.example/other", "https://c.example/", 3); + + const posted: string[] = []; + const fetchImpl: FetchLike = vi.fn(async (url, init) => { + if (init?.method === "POST") { + posted.push(new URLSearchParams(init.body as string).get("target")!); + return new Response(null, { status: 202 }); + } + return new Response("", { + headers: { + link: `<${new URL("/wm", url).href}>; rel="webmention"`, + "content-type": "text/html", + }, + }); + }); + + const results = await resendForDeletedSource(source, { + fetch: fetchImpl, + sentLog, + }); + expect(posted.sort()).toEqual(["https://a.example/", "https://b.example/"]); + expect(results.every((r) => r.delivered)).toBe(true); + expect(await sentLog.listTargets(source)).toEqual([]); + expect(await sentLog.listTargets("https://me.example/other")).toEqual([ + "https://c.example/", + ]); + }); + + it("clears a target that no longer declares an endpoint", async () => { + const sentLog = memorySentLog(); + await sentLog.record(source, target, 1); + const noEndpoint: FetchLike = vi.fn( + async () => + new Response("

gone

", { + headers: { "content-type": "text/html" }, + }), + ); + const results = await resendForDeletedSource(source, { + fetch: noEndpoint, + sentLog, + }); + expect(results).toEqual([ + { target, endpoint: null, delivered: false, status: 0 }, + ]); + expect(await sentLog.listTargets(source)).toEqual([]); + }); + + it("keeps the row when the endpoint rejects the re-send, for a later retry", async () => { + const sentLog = memorySentLog(); + await sentLog.record(source, target, 1); + const results = await resendForDeletedSource(source, { + fetch: endpointFetch(500), + sentLog, + }); + expect(results[0]?.delivered).toBe(false); + expect(await sentLog.listTargets(source)).toEqual([target]); + }); +}); + describe("sendWebmentions", () => { it("notifies every target, preserving order", async () => { const fetchImpl: FetchLike = vi.fn(async (url, init) => diff --git a/packages/webmention/src/sender.ts b/packages/webmention/src/sender.ts index 11b6a091..7e8a6b3c 100644 --- a/packages/webmention/src/sender.ts +++ b/packages/webmention/src/sender.ts @@ -18,6 +18,7 @@ import { import { safeFetch, type FetchLike } from "@dwk/safe-fetch"; import { discoverEndpoint } from "./discovery.js"; import { WebmentionLogEvent } from "./log.js"; +import type { SentLog } from "./sent-log.js"; /** Options for {@link sendWebmention} / {@link sendWebmentions}. */ export interface SendOptions { @@ -34,6 +35,14 @@ export interface SendOptions { * enable in a production composition. */ readonly fetchAllowedHosts?: readonly string[]; + /** + * Opt-in delivered-notification log (see `sent-log.ts`). When supplied, + * every accepted notification is recorded so + * {@link resendForDeletedSource} can honor Webmention §3.1.5 after the + * source is deleted. Log writes are best-effort: a failed write never + * fails the send that succeeded. + */ + readonly sentLog?: SentLog; } /** Outcome of attempting to notify a single target. */ @@ -118,12 +127,24 @@ export async function sendWebmention( return logOutcome({ target, endpoint, delivered: false, status: 0 }); } - return logOutcome({ + const outcome = logOutcome({ target, endpoint, delivered: response.ok, status: response.status, }); + if (outcome.delivered && options?.sentLog) { + // Best-effort: the notification already succeeded, so a failed log write + // only costs a future §3.1.5 re-send, never the send itself. + try { + await options.sentLog.record(source, target, Date.now()); + } catch { + logger.warn(WebmentionLogEvent.SentLogWriteFailed, { + targetHost: hostFromUrl(target), + }); + } + } + return outcome; } /** @@ -140,3 +161,58 @@ export function sendWebmentions( targets.map((target) => sendWebmention(source, target, options)), ); } + +/** Options for {@link resendForDeletedSource}: a {@link SentLog} is required. */ +export interface ResendOptions extends SendOptions { + /** The log the original sends recorded into — the targets to re-notify. */ + readonly sentLog: SentLog; +} + +/** + * Webmention §3.1.5 (SHOULD): after `source` has been deleted (it now serves + * `410 Gone`, ideally with a tombstone), re-send a Webmention to every target + * the sent log recorded for it, so each receiver re-fetches the source, sees + * it gone, and drops the stored mention. + * + * Call this *after* the deletion is live — a receiver that re-verifies against + * a still-200 source will keep the mention. Log rows are cleared for targets + * whose re-notification was accepted (or that no longer declare an endpoint — + * there is nothing left to notify); a target whose endpoint failed keeps its + * row so a later call can retry. Failures are reported per target, never + * thrown. + */ +export async function resendForDeletedSource( + source: string, + options: ResendOptions, +): Promise { + const { sentLog } = options; + const logger = options.logger ?? noopLogger; + const metrics = options.metrics ?? noopMetrics; + const targets = await sentLog.listTargets(source); + const results = await Promise.all( + targets.map(async (target) => { + // Plain sendWebmention, minus the delivered-notification recording: a + // re-send tears the log entry down rather than refreshing it. + const result = await sendWebmention(source, target, { + ...options, + sentLog: undefined, + }); + if (result.delivered || result.endpoint === null) { + try { + await sentLog.remove(source, target); + } catch { + // Best-effort, like the record path: a kept row only means a + // harmless duplicate re-send on the next call. + } + } + return result; + }), + ); + const fields = { + targets: results.length, + delivered: results.filter((r) => r.delivered).length, + }; + logger.info(WebmentionLogEvent.ResendCompleted, fields); + metrics.count(WebmentionLogEvent.ResendCompleted, fields); + return results; +} diff --git a/packages/webmention/src/sent-log.test.ts b/packages/webmention/src/sent-log.test.ts new file mode 100644 index 00000000..12989150 --- /dev/null +++ b/packages/webmention/src/sent-log.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { env } from "cloudflare:test"; +import { createD1SentLog } from "./sent-log.js"; + +interface TestEnv { + WEBMENTION_INBOX: import("@cloudflare/workers-types").D1Database; +} + +const db = (env as unknown as TestEnv).WEBMENTION_INBOX; + +describe("createD1SentLog", () => { + it("creates its table on first use, records, lists, and removes", async () => { + const log = createD1SentLog(db, { table: "wm_sent_basic" }); + await log.record("https://me.example/p", "https://a.example/", 1000); + await log.record("https://me.example/p", "https://b.example/", 2000); + await log.record("https://me.example/q", "https://c.example/", 500); + + expect(await log.listTargets("https://me.example/p")).toEqual([ + "https://a.example/", + "https://b.example/", + ]); + + await log.remove("https://me.example/p", "https://a.example/"); + expect(await log.listTargets("https://me.example/p")).toEqual([ + "https://b.example/", + ]); + // Other sources' rows are untouched. + expect(await log.listTargets("https://me.example/q")).toEqual([ + "https://c.example/", + ]); + }); + + it("upserts on the (source, target) pair", async () => { + const log = createD1SentLog(db, { table: "wm_sent_upsert" }); + await log.record("https://me.example/p", "https://a.example/", 1000); + await log.record("https://me.example/p", "https://a.example/", 2000); + expect(await log.listTargets("https://me.example/p")).toEqual([ + "https://a.example/", + ]); + }); + + it("removing an absent pair is a no-op", async () => { + const log = createD1SentLog(db, { table: "wm_sent_noop" }); + await expect( + log.remove("https://me.example/p", "https://a.example/"), + ).resolves.toBeUndefined(); + }); + + it("rejects an unsafe table identifier", () => { + expect(() => createD1SentLog(db, { table: "bad; DROP" })).toThrow( + /invalid sent-log table name/, + ); + }); +}); diff --git a/packages/webmention/src/sent-log.ts b/packages/webmention/src/sent-log.ts new file mode 100644 index 00000000..c72500f7 --- /dev/null +++ b/packages/webmention/src/sent-log.ts @@ -0,0 +1,100 @@ +/** + * `@dwk/webmention` — sent-mention log. + * + * The sender is stateless per call, but Webmention §3.1.5 (a SHOULD) asks the + * publisher to re-send its Webmentions when a previously-published source is + * later deleted, so each receiver re-fetches the source, sees the `410 Gone` + * (or `404`), and drops the stored mention. Re-sending requires remembering + * who was notified: this module is that memory — an opt-in log of + * `(source, target)` pairs whose notification was accepted, written by the + * sender when a {@link SentLog} is supplied, and replayed by + * `resendForDeletedSource` after the source is gone. The default is a + * D1-backed log (strongly consistent — never KV, per + * `spec/non-functional-requirements.md`), sharing the receiver's D1 binding + * but its own table. See `spec/packages/webmention.md`. + * + * @packageDocumentation + */ + +import type { D1Database } from "@cloudflare/workers-types"; + +/** Persistence surface for the sender's delivered-notification log. */ +export interface SentLog { + /** Upsert a delivered notification, keyed on `(source, target)`. */ + record(source: string, target: string, sentAt: number): Promise; + /** Targets previously notified for `source`, oldest first. */ + listTargets(source: string): Promise; + /** Drop one `(source, target)` pair; no-op when absent. */ + remove(source: string, target: string): Promise; +} + +/** Options for {@link createD1SentLog}. */ +export interface D1SentLogOptions { + /** Table name to use; created if absent. Defaults to `webmentions_sent`. */ + readonly table?: string; +} + +/** + * Build a D1-backed {@link SentLog}. The backing table is created on first + * use if it does not already exist. + */ +export function createD1SentLog( + db: D1Database, + options?: D1SentLogOptions, +): SentLog { + const table = options?.table ?? "webmentions_sent"; + // Guard the identifier: it is interpolated into DDL, so only allow a safe + // set of characters rather than trusting the caller blindly. + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) { + throw new Error(`@dwk/webmention: invalid sent-log table name "${table}".`); + } + + let ready: Promise | null = null; + const ensureSchema = (): Promise => { + ready ??= db + .prepare( + `CREATE TABLE IF NOT EXISTS ${table} (` + + `source TEXT NOT NULL, ` + + `target TEXT NOT NULL, ` + + `sent_at INTEGER NOT NULL, ` + + `PRIMARY KEY (source, target))`, + ) + .run() + .then(() => {}); + return ready; + }; + + return { + async record(source, target, sentAt) { + await ensureSchema(); + await db + .prepare( + `INSERT INTO ${table} (source, target, sent_at) ` + + `VALUES (?1, ?2, ?3) ` + + `ON CONFLICT (source, target) DO UPDATE SET sent_at = excluded.sent_at`, + ) + .bind(source, target, sentAt) + .run(); + }, + + async listTargets(source) { + await ensureSchema(); + const { results } = await db + .prepare( + `SELECT target FROM ${table} WHERE source = ?1 ` + + `ORDER BY sent_at ASC, target ASC`, + ) + .bind(source) + .all<{ target: string }>(); + return results.map((row) => row.target); + }, + + async remove(source, target) { + await ensureSchema(); + await db + .prepare(`DELETE FROM ${table} WHERE source = ?1 AND target = ?2`) + .bind(source, target) + .run(); + }, + }; +} diff --git a/spec/packages/webmention.md b/spec/packages/webmention.md index f33b63f8..7c752269 100644 --- a/spec/packages/webmention.md +++ b/spec/packages/webmention.md @@ -76,6 +76,17 @@ Receives and sends Webmentions for the user's domain. - Discover Webmention endpoints for outbound links. - Notify targets **on publish**. +- **Re-notify targets on delete (§3.1.5, a SHOULD).** The sender is stateless + per call, so re-sending is opt-in via a `SentLog` (`sent-log.ts`): when one + is supplied in `SendOptions`, every accepted notification is recorded + (D1-backed default `createD1SentLog`, own `webmentions_sent` table, same + strongly-consistent-store rule as the inbox). After the source is deleted + and serving `410 Gone`, `resendForDeletedSource(source, options)` re-sends + to every recorded target so each receiver re-verifies, finds the source + gone, and drops the mention. Log rows are cleared for targets that accepted + the re-send (or no longer declare an endpoint); failed targets keep their + row for a later retry. Log writes are best-effort — a failed write never + fails the send that succeeded. ### Federation handoff (documented config, not core code) @@ -102,9 +113,8 @@ Receives and sends Webmentions for the user's domain. ### Known gaps -- **Deleted-source re-send (§3.1.5, a SHOULD).** When a previously sent source - is later deleted, the sender does not re-send a Webmention so the receiver can - drop the mention. This is an intentional scope limit: the receiver already - removes a mention when asynchronous re-verification finds the link gone - (including a `410 Gone` source), so the inbox stays correct on the receiving - side. Re-sending on delete from the publishing side is deferred. +- None currently. The last one — the §3.1.5 deleted-source re-send — is now + implemented on the publishing side (see the sender's opt-in `SentLog` + + `resendForDeletedSource`); the receiving side already dropped a mention + when asynchronous re-verification found the link gone (including a + `410 Gone` source). From 65df1531ac0dca28401992eadd30730859ec201a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 03:54:26 +0000 Subject: [PATCH 4/4] feat(activitypub,mastodon-api): surface follow notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the deferred phase-2 gap: #onFollow stores a new follower's Follow (or FEP-1b12 Group membership Join) in the actor's inbox — a re-Follow from a still-recorded follower is not a fresh notification — the __client/notifications classifier surfaces those rows, and notificationEntity maps them to Mastodon's type "follow" so clients like Tusky and Pixelfed see new-follower notifications. The inbox path also queues the follower's actor-profile fetch for name/avatar hydration. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XT45PcE3pgXgsQffQDWdhW --- .changeset/mastodon-follow-notifications.md | 6 ++ packages/activitypub/src/mastodon-api.test.ts | 62 +++++++++++++++++++ packages/activitypub/src/object.test.ts | 37 ++++++----- packages/activitypub/src/object.ts | 29 ++++++--- packages/mastodon-api/src/entities.ts | 18 ++++-- .../mastodon-api/src/notifications.test.ts | 58 ++++++++++++++--- packages/mastodon-api/src/notifications.ts | 2 +- spec/packages/mastodon-api.md | 26 +++----- 8 files changed, 187 insertions(+), 51 deletions(-) create mode 100644 .changeset/mastodon-follow-notifications.md diff --git a/.changeset/mastodon-follow-notifications.md b/.changeset/mastodon-follow-notifications.md new file mode 100644 index 00000000..78d70434 --- /dev/null +++ b/.changeset/mastodon-follow-notifications.md @@ -0,0 +1,6 @@ +--- +"@dwk/activitypub": minor +"@dwk/mastodon-api": minor +--- + +Implement `follow` notifications (the deferred phase-2 gap): `@dwk/activitypub`'s `#onFollow` now stores a _new_ follower's `Follow` (or FEP-1b12 `Group` membership `Join`) in the actor's inbox — a re-Follow from a still-recorded follower is not a fresh notification — and the `__client/notifications` classifier surfaces those rows; `@dwk/mastodon-api`'s `notificationEntity` maps them to Mastodon's `type: "follow"` (account attached, `status: null`), so clients like Tusky and Pixelfed now see new-follower notifications. Storing via the existing inbox path also queues the follower's actor-profile fetch, so the notification renders with a real display name and avatar once hydrated. diff --git a/packages/activitypub/src/mastodon-api.test.ts b/packages/activitypub/src/mastodon-api.test.ts index 848621ea..0a65fdb2 100644 --- a/packages/activitypub/src/mastodon-api.test.ts +++ b/packages/activitypub/src/mastodon-api.test.ts @@ -618,6 +618,68 @@ describe("buildMastodonBackend", () => { ); }); + it("notifications() surfaces a new follower's Follow once — a re-Follow is not a fresh notification", async () => { + // manuallyApprovesFollowers avoids an outbound actor fetch in this DO. + const config = resolveConfig({ + baseUrl: "https://owner.example", + actor: { + username: `owner-${crypto.randomUUID().slice(0, 8)}`, + manuallyApprovesFollowers: true, + }, + publicKeyPem: "PUBLIC-PEM", + }); + const follow = (id: string) => ({ + "@context": "https://www.w3.org/ns/activitystreams", + id: `https://remote.example/activities/${id}`, + type: "Follow", + actor: "https://remote.example/users/frank", + object: config.iris.id, + }); + await seedActivity(config, follow("follow-1")); + // A distinct re-Follow (fresh id, so not caught by activity dedup) from + // the same, still-recorded follower is not a new notification. + await seedActivity(config, follow("follow-2")); + const backend = buildMastodonBackend({ config, actor: testEnv.ACTOR }); + + const page = await backend.notifications({ limit: 10 }); + expect(page.entries).toHaveLength(1); + expect(page.entries[0]?.activity["type"]).toBe("Follow"); + + // After an unfollow, following again is a genuinely new follower — and a + // fresh notification alongside the historical one. + await seedActivity(config, { + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://remote.example/activities/unfollow-1", + type: "Undo", + actor: "https://remote.example/users/frank", + object: follow("follow-1"), + }); + await seedActivity(config, follow("follow-3")); + const after = await backend.notifications({ limit: 10 }); + expect(after.entries).toHaveLength(2); + }); + + it("notifications() excludes a misaddressed Follow (never recorded)", async () => { + const config = resolveConfig({ + baseUrl: "https://owner.example", + actor: { + username: `owner-${crypto.randomUUID().slice(0, 8)}`, + manuallyApprovesFollowers: true, + }, + publicKeyPem: "PUBLIC-PEM", + }); + await seedActivity(config, { + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://remote.example/activities/misaddressed", + type: "Follow", + actor: "https://remote.example/users/frank", + object: "https://someone-else.example/users/other", + }); + const backend = buildMastodonBackend({ config, actor: testEnv.ACTOR }); + const page = await backend.notifications({ limit: 10 }); + expect(page.entries).toHaveLength(0); + }); + it("caps the inbox scan instead of exhausting a plain-post-dominated table", async () => { const config = freshConfig(); const timestamp = Date.now(); diff --git a/packages/activitypub/src/object.test.ts b/packages/activitypub/src/object.test.ts index 14675f82..9f880db1 100644 --- a/packages/activitypub/src/object.test.ts +++ b/packages/activitypub/src/object.test.ts @@ -2979,30 +2979,34 @@ describe("__client/notifications", () => { }); }); - it("classifies Like as favourite and Announce as reblog, omits Follow", async () => { + it("classifies Like as favourite, Announce as reblog, and a new follower's Follow as follow", async () => { const { username, iris, stub } = freshUser(); await runInDurableObject(stub, async (instance, state) => { - // Follows never reach `inbox` at all (only `followers`/`pending_accept` - // rows) — deliver one to document that gap rather than silently relying - // on it, and confirm no row lands in `inbox` regardless. + // A *new* follower's Follow is stored in `inbox` (alongside its + // `followers` row) so the notifications read can surface it; a distinct + // re-Follow (fresh activity id, same still-recorded follower) is not a + // fresh notification and stores no second row. + const follow = (id: string) => + JSON.stringify({ + id: `https://remote.example/activities/${id}`, + type: "Follow", + actor: REMOTE, + object: iris.id, + }); const followRes = await instance.fetch( - inboxRequest( - username, - JSON.stringify({ - id: "https://remote.example/activities/notif-follow", - type: "Follow", - actor: REMOTE, - object: iris.id, - }), - ), + inboxRequest(username, follow("notif-follow")), ); expect(followRes.status).toBe(202); + const refollowRes = await instance.fetch( + inboxRequest(username, follow("notif-refollow")), + ); + expect(refollowRes.status).toBe(202); const followRows = state.storage.sql .exec<{ n: number; }>(`SELECT COUNT(*) AS n FROM inbox WHERE json LIKE '%"Follow"%'`) .one().n; - expect(followRows).toBe(0); + expect(followRows).toBe(1); await instance.fetch( inboxRequest( @@ -3031,9 +3035,10 @@ describe("__client/notifications", () => { const body = (await res.json()) as { items: { activity: { id: string; type: string } }[]; }; - expect(body.items).toHaveLength(2); + expect(body.items).toHaveLength(3); expect(body.items.map((i) => i.activity.type).sort()).toEqual([ "Announce", + "Follow", "Like", ]); expect( @@ -3041,7 +3046,7 @@ describe("__client/notifications", () => { (i) => i.activity.id === "https://remote.example/activities/notif-follow", ), - ).toBe(false); + ).toBe(true); }); }); diff --git a/packages/activitypub/src/object.ts b/packages/activitypub/src/object.ts index 2098b827..905ac910 100644 --- a/packages/activitypub/src/object.ts +++ b/packages/activitypub/src/object.ts @@ -583,11 +583,23 @@ export class ActivityPubObject extends DurableObject { // Record the follower first (inbox filled in on the auto-accept path), so a // manually-approved actor never triggers an outbound actor fetch here. const now = Date.now(); + const alreadyFollowing = + this.#sql + .exec(`SELECT 1 FROM followers WHERE actor = ?`, follower) + .toArray().length > 0; this.#sql.exec( `INSERT OR IGNORE INTO followers (actor, inbox, added_at) VALUES (?, NULL, ?)`, follower, now, ); + // A *new* follower is also stored in `inbox` so the Mastodon client API's + // notifications read surfaces it as a `follow` (see #classifyClientEntry); + // a re-Follow from an existing follower is not a fresh notification. This + // also queues the follower's actor-profile fetch, so the notification + // renders with a real display name/avatar. + if (!alreadyFollowing) { + await this.#storeInbox(activity); + } if (config.manuallyApprovesFollowers) return; // An unsafe target is rejected synchronously (no network, no queue row) — @@ -608,9 +620,9 @@ export class ActivityPubObject extends DurableObject { /** Handle `Undo` of a `Follow` (unfollow); other undos are ignored. */ #onUndo(activity: ActivityObject): void { // Only an embedded `Follow` object is an unfollow. A bare string `object` - // is an activity IRI we cannot classify (we do not store inbound `Follow`s), - // so treating it as a `Follow` would let an `Undo Like`/`Undo Announce` - // carrying a string id silently drop a follower. Require the typed form. + // is an activity IRI this handler does not resolve, so treating it as a + // `Follow` would let an `Undo Like`/`Undo Announce` carrying a string id + // silently drop a follower. Require the typed form. if (objectType(activity.object) !== "Follow") return; const follower = actorIri(activity.actor); if (follower) @@ -1464,12 +1476,13 @@ export class ActivityPubObject extends DurableObject { * Read-time, over the parsed activity JSON — `object_type` alone can't * distinguish these (it reflects the *embedded object's* type, not the * activity's own, and is null for bare-IRI objects like most `Like`s). - * `Follow` is deliberately absent: inbound Follows never reach `inbox` - * (see docs/superpowers/specs/2026-07-21-mastodon-phase2-implementation-notes.md). + * A `Follow` (or a FEP-1b12 membership `Join`, its synonym on a `Group` + * actor) reaches `inbox` only when `#onFollow` recorded a *new* follower, + * so every stored one is a `follow` notification. */ #classifyClientEntry( activity: ActivityObject, - ): "timeline" | "favourite" | "reblog" | "mention" | null { + ): "timeline" | "favourite" | "reblog" | "mention" | "follow" | null { const type = activity.type; if (type === "Create" || type === "Update") { // A reply/mention targeting this actor is a notification, not a @@ -1499,6 +1512,7 @@ export class ActivityPubObject extends DurableObject { } if (type === "Like") return "favourite"; if (type === "Announce") return "reblog"; + if (type === "Follow" || type === "Join") return "follow"; return null; } @@ -1666,7 +1680,8 @@ export class ActivityPubObject extends DurableObject { ? classification === "timeline" : classification === "favourite" || classification === "reblog" || - classification === "mention"; + classification === "mention" || + classification === "follow"; if (wanted) { matches.push({ seq: row.seq, diff --git a/packages/mastodon-api/src/entities.ts b/packages/mastodon-api/src/entities.ts index 1c88fd24..c56a0dd4 100644 --- a/packages/mastodon-api/src/entities.ts +++ b/packages/mastodon-api/src/entities.ts @@ -421,10 +421,11 @@ export function statusEntity( } /** - * `Like`/`Announce`/reply-`Create` row → `Notification`, or `null` if the - * row fits none of the phase-2 notification types (design doc: "Rows that - * fit no type are omitted from this endpoint"). `Follow` is deliberately - * unhandled — deferred to phase 3, not an oversight. + * `Like`/`Announce`/reply-`Create`/`Follow` row → `Notification`, or `null` + * if the row fits none of the notification types (design doc: "Rows that + * fit no type are omitted from this endpoint"). A `Join` is the FEP-1b12 + * membership synonym for `Follow` on a `Group` actor and renders the same + * `follow` notification. */ export function notificationEntity( entry: BackendEntry, @@ -465,6 +466,15 @@ export function notificationEntity( status: null, }; } + if (type === "Follow" || type === "Join") { + return { + id: entry.id, + type: "follow", + created_at: new Date(entry.receivedAt).toISOString(), + account, + status: null, + }; + } if (type === "Create") { const object = activity.object; const inReplyTo = diff --git a/packages/mastodon-api/src/notifications.test.ts b/packages/mastodon-api/src/notifications.test.ts index 0bdea2b1..8911f706 100644 --- a/packages/mastodon-api/src/notifications.test.ts +++ b/packages/mastodon-api/src/notifications.test.ts @@ -35,7 +35,7 @@ describe("GET /api/v1/notifications", () => { expect(response.status).toBe(422); }); - it("maps a Like row to a favourite notification, dropping unmapped rows", async () => { + it("maps Like and Follow rows to notifications, dropping unmapped rows", async () => { await resetDb(); const token = await obtainAccessToken(); const like = { @@ -49,8 +49,6 @@ describe("GET /api/v1/notifications", () => { object: "https://owner.example/users/owner/outbox/1", }, }; - // A row that notificationEntity maps to null (a Follow, unhandled until - // phase 3) must not leak a `null` entry into the response array. const follow = { id: encodeSnowflake(1_753_000_000_011, 1), receivedAt: 1_753_000_000_011, @@ -62,19 +60,65 @@ describe("GET /api/v1/notifications", () => { object: "https://owner.example/users/owner", }, }; - const cfg = { ...testConfig, backend: fakeBackend([like, follow]) }; + // A row that notificationEntity maps to null (an activity type with no + // notification shape) must not leak a `null` entry into the response. + const unmapped = { + id: encodeSnowflake(1_753_000_000_012, 1), + receivedAt: 1_753_000_000_012, + objectType: null, + relayedBy: null, + activity: { + type: "Block", + actor: "https://remote.example/users/eve", + object: "https://owner.example/users/owner", + }, + }; + const cfg = { + ...testConfig, + backend: fakeBackend([like, follow, unmapped]), + }; const response = await api(cfg)( new Request("https://owner.example/api/v1/notifications", { headers: { authorization: `Bearer ${token}` }, }), ); expect(response.status).toBe(200); - const body = (await response.json()) as { type: string }[]; - expect(body).toHaveLength(1); - expect(body[0]?.type).toBe("favourite"); + const body = (await response.json()) as { + type: string; + status: unknown; + account: { acct: string }; + }[]; + expect(body.map((n) => n.type)).toEqual(["favourite", "follow"]); + expect(body[1]?.status).toBeNull(); + expect(body[1]?.account.acct).toBe("dave@remote.example"); expect(body.every((n) => n !== null)).toBe(true); }); + it("maps a FEP-1b12 Join row to a follow notification", async () => { + await resetDb(); + const token = await obtainAccessToken(); + const join = { + id: encodeSnowflake(1_753_000_000_013, 1), + receivedAt: 1_753_000_000_013, + objectType: null, + relayedBy: null, + activity: { + type: "Join", + actor: "https://remote.example/users/erin", + object: "https://owner.example/users/owner", + }, + }; + const cfg = { ...testConfig, backend: fakeBackend([join]) }; + const response = await api(cfg)( + new Request("https://owner.example/api/v1/notifications", { + headers: { authorization: `Bearer ${token}` }, + }), + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { type: string }[]; + expect(body.map((n) => n.type)).toEqual(["follow"]); + }); + it("returns a Link header when the page has real entries", async () => { await resetDb(); const token = await obtainAccessToken(); diff --git a/packages/mastodon-api/src/notifications.ts b/packages/mastodon-api/src/notifications.ts index 8235192d..55e371c0 100644 --- a/packages/mastodon-api/src/notifications.ts +++ b/packages/mastodon-api/src/notifications.ts @@ -1,4 +1,4 @@ -/** `GET /api/v1/notifications` — favourite/reblog/mention only in phase 2 (Follow deferred to #350). */ +/** `GET /api/v1/notifications` — favourite/reblog/mention, plus follow once the backend stores inbound Follows. */ import { authenticateBearer } from "./auth.js"; import { notificationEntity } from "./entities.js"; diff --git a/spec/packages/mastodon-api.md b/spec/packages/mastodon-api.md index d9ee4fa6..cd37012d 100644 --- a/spec/packages/mastodon-api.md +++ b/spec/packages/mastodon-api.md @@ -202,9 +202,11 @@ for the full cursor contract (`max_received_at`/`since_received_at`/ `tags: []`, `emojis: []`, `card: null`, `poll: null`. - **`Notification`**: `Like` → `favourite`, `Announce` → `reblog`, a `Create` whose `inReplyTo` targets this instance → `mention` (with the - full mapped `Status` attached); any other row maps to `null` and is - omitted from the page. **`Follow` has no case in phase 2** — see Known - gaps below. + full mapped `Status` attached); `Follow` (or its FEP-1b12 `Group` + membership synonym `Join`) → `follow` — `@dwk/activitypub`'s `#onFollow` + stores the activity in `inbox` for each *new* follower, so a re-Follow + from an existing follower is not a fresh notification; any other row maps + to `null` and is omitted from the page. - **Remote `Account`** (embedded in `Status.account` / `Notification.account`): synthesized purely from the actor IRI, no backend call and no outbound fetch (`spec/mastodon-client-api.md`: @@ -230,15 +232,6 @@ text is correct behavior, not a bug. ## Known gaps (phase 2) -- **Follow notifications are deferred to phase 3 (#350).** `#onFollow` - writes only to the `followers`/`pending_accept` tables — inbound - `Follow` activities never reach `inbox`, so `GET /api/v1/notifications` - has no data to classify as `follow` and never emits one. This was a - scope decision (confirmed with the repo owner), not an oversight — - phase 2 was scoped as additive DO routes only, and teaching `#onFollow` - to also write an `inbox` row (or merging a second `followers`-sourced - stream into the notification cursor) is real federation-write-path - surgery. - **Bare-IRI `Announce` objects render as empty statuses.** A plain (non-relayed) boost's `object` is often just the boosted post's IRI as a string, not an embedded object; `statusEntity` reads object fields @@ -263,7 +256,8 @@ counters from stored inbox activity. The Pixelfed and Tusky runs remain the acceptance gate in `conformance/mastodon-client-qa.md`; record/fix client quirks there before marking the conformance suite passing. -Follow notifications and `in_reply_to_id` threading remain known fidelity -gaps: inbound `Follow` activities are intentionally not persisted in the -inbox, and a remote reply target cannot yet be reliably translated to a local -snowflake id. +`in_reply_to_id` threading remains a known fidelity gap: a remote reply +target cannot yet be reliably translated to a local snowflake id. Follow +notifications are implemented — `@dwk/activitypub` stores each new +follower's `Follow`/`Join` in the inbox and the notifications read maps it +to `type: "follow"`.