From 1e6436e089a6f164f29988d5a1f41735ed80b194 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Wed, 7 Oct 2026 11:16:05 -0500 Subject: [PATCH] capsite: social.coop tree at the ActivityPub level (fedi-cache) Mastodon is one implementation; the cache speaks the protocol: outbox for backfill, and fedi-cache as a follower actor so Update/Delete are delivered rather than polled for. Its own keypair replaces an access token, so webapp drops out of this one. Co-Authored-By: Claude Opus 5.5 (1M context) --- social-coop-checklist.md | 150 ++++++++++++++++++++------------------- 1 file changed, 78 insertions(+), 72 deletions(-) diff --git a/social-coop-checklist.md b/social-coop-checklist.md index d9575e1..51658c7 100644 --- a/social-coop-checklist.md +++ b/social-coop-checklist.md @@ -4,84 +4,90 @@ The same conventions as [the diigo goal tree](capsite-checklist.md): each `- [ ]` is a goal, its indented children must pass first, working notes sit under each goal. -- [ ] **madmode's weekly roundup includes my social.coop activity** +- [ ] **madmode's weekly roundup includes my fediverse activity** ([#23](https://github.com/dckc/madmode-blog/issues/23), PESOS): - a cache of [@dckc@social.coop](https://social.coop/@dckc) — posts, - boosts, and (with discretion) bookmarks and favourites; the access - token stays inside webapp, so madmode holds no credential + `fedi-cache` holds the posts and boosts of + [@dckc@social.coop](https://social.coop/@dckc), speaking + ActivityPub only — no server-specific API, so the cache works + against any server and survives an account move. social.coop + grants it nothing; madmode holds no credential - [ ] **0. Decide the shape** (user's call before code): - - [ ] 0-i. New worker `masto-cache/` beside `diigo-cache/`, or - generalize diigo-cache? Recommendation: a new worker; - extract shared pieces once two caches work. - - [ ] 0-ii. Which feeds? Public posts and boosts need no - credential (`GET /api/v1/accounts/:id/statuses`, or the - ActivityPub outbox). Bookmarks, favourites, and - followers-only posts need a token. Bookmarks are private - by nature: publish them only behind an explicit opt-in - (what counts as opting in is open). - - [ ] 0-iii. Mastodon API or ActivityPub? The API is simpler - and covers bookmarks/favourites; ActivityPub works - against any server (portability if the account moves). - Recommendation: the API now, keyed by the post's - ActivityPub `uri` so a later move keeps ids stable. + - [ ] 0-i. A new worker `fedi-cache/` beside `diigo-cache/`; + extract shared pieces (date window/`q` query module, fill + job, webkey page) once two caches work. + - [ ] 0-ii. Pull, push, or both? **Pull**: read the actor's + `outbox` (an `OrderedCollection` of `Create` and + `Announce`) — public posts only. **Push**: fedi-cache is + itself an actor that follows @dckc; social.coop delivers + `Create`/`Update`/`Delete`/`Announce` to its inbox, + including followers-only posts once the follow is + approved. Recommendation: both — outbox for backfill, + inbox for freshness. + - [ ] 0-iii. What's out of reach at the protocol level: likes + (`Like` goes to the post's author, not to followers, and + Mastodon serves no `liked` collection) and bookmarks (not + ActivityPub at all). Accept the gap, or take them from + the account archive (`likes.json`, `bookmarks.json`) as a + manual import. - - [ ] **1. Credential discipline**: a token from social.coop → - Preferences → Development → New application, scopes - **read-only and narrow** (`read:statuses read:accounts - read:bookmarks read:favourites`, per 0-ii) — attenuation at - the source. It rides as `Authorization: Bearer`, which webapp's - mint form already blanks. Token → webapp DO storage only; ids - → password manager. Revoking the app on social.coop is the - kill switch. + - [ ] **1. Authority**: fedi-cache's own actor keypair, generated in + its DO and never exported; HTTP Signatures on outgoing GETs + (servers in authorized-fetch mode require them) and on the + `Follow`. The authority it holds is exactly what @dckc grants by + approving the follow, and removing the follower revokes it. No + token, no cookie, so webapp isn't needed. Ids → password manager, + as with diigo. - - [ ] **2. The cache** (`masto-cache/`, the 1c pattern from diigo): - - [ ] 2-i. Mint and wire against `https://social.coop/api/`; - `accounts/lookup?acct=dckc` gives the account id. - - [ ] 2-ii. Storage: one table per feed (statuses, bookmarks, - favourites), keyed by `uri`, storing the status JSON and - its `edited_at`. **Durable state** from day one: a - `SCHEMA_V1` step. - - [ ] 2-iii. Fill: page backward with `max_id` (40 per page) - following the `Link` header — bookmarks and favourites - paginate *only* by `Link` (their cursors aren't status - ids). Honor `X-RateLimit-Remaining`/`-Reset` (300 per - 5 minutes is Mastodon's default). + - [ ] **2. Pull: the outbox cache**: + - [ ] 2-i. Resolve the actor: WebFinger + (`/.well-known/webfinger?resource=acct:dckc@social.coop`) + → actor document → `outbox`. + - [ ] 2-ii. Storage: activities and objects keyed by their + `id` URI, objects with `updated`. **Durable state** from + day one: a `SCHEMA_V1` step. + - [ ] 2-iii. Fill: follow the collection's `first`/`next` pages + with `Accept: application/activity+json`; politeness + delay per request; back off on 429. - [ ] 2-iv. Tests: `pnpm lint && pnpm test`, the real worker - against a fake Mastodon, including `Link` pagination and - a rate-limit response. - - [ ] 2-v. Complete: a filled cache matches the account archive - (Preferences → Import and export → Request your archive: - `outbox.json`, `likes.json`, `bookmarks.json`), the - analogue of diigo's `scripts/compare-export.ts`. + against a fake ActivityPub server (WebFinger, actor, + paged outbox, a 429). + - [ ] 2-v. Complete: a filled cache matches the account + archive's `outbox.json` — the archive is itself an + ActivityPub collection, so the comparison stays at the + protocol level (the analogue of diigo's + `scripts/compare-export.ts`). - - [ ] **3. Freshness**: new posts are cheap (`min_id` = newest - stored, page forward). Edits and deletions of older posts are - not announced to an API poller; options: re-fetch the last N - days on each refresh (the window a roundup cares about), or - follow the streaming API. A test: a post added, one edited, and - one deleted in the fake; the weekly window reflects all three. - A cron alarm in the cache DO drives it. + - [ ] **3. Push: fedi-cache as a follower**: + - [ ] 3-i. Serve its own actor: WebFinger, actor document with + `publicKey`, `inbox`. Needs a public HTTPS origin, so dev + wants a tunnel or a deployed test instance (and the user + runs it, per AGENTS.md). + - [ ] 3-ii. Send `Follow`; record `Accept`. + - [ ] 3-iii. Inbox: verify the HTTP Signature against the + sender's key, accept only activities whose `actor` is + @dckc, apply `Create`/`Update`/`Delete`/`Announce`/`Undo`. + - [ ] 3-iv. Tests: signed deliveries in; a forged one and one + from another actor rejected; an edit and a delete reflected. - - [ ] **4. Read surfaces**: `/public/dckc` with the date window and - `q` filter of diigo's 1d (the same query module, if 0-i shares - it), HTML and JSON; JSON shape pinned by a test. Serve only - `visibility: public` posts (and opted-in bookmarks) there — - followers-only and direct posts never reach a public page; - a test says so. + - [ ] **4. Read surfaces**: `/public/dckc` with diigo 1d's date + window and `q` filter, HTML and JSON; JSON shape pinned by a + test. Only objects addressed to `as:Public` appear there: + followers-only posts (which only push brings in) never reach a + public page; a test says so. - [ ] **5. madmode's weekly page includes it**: diigo's goal 4 (`/bookmarks/2026-W41`) grows a section from this cache, or becomes a combined `/weekly/2026-W41`. Same editorial question - as there: raw list, or a draft the user edits. + as there: a raw list, or a draft the user edits. - - [ ] **6. Deployed**: after diigo's goal 5; rate-limit `/public/*`; - mint against prod; fill. + - [ ] **6. Deployed**: rate-limit `/public/*`; the actor's origin is + its identity (moving it changes the follower), so choose the + hostname once. - - [ ] **7. Token revocation**: when social.coop rejects the token - (401), refreshes fail; the public page keeps serving what's - stored and says so. Re-mint is manual; same re-wire question - as diigo's goal 6. + - [ ] **7. Revocation**: if the follow is removed or deliveries stop, + the public page keeps serving what's stored and says when it + last heard from @dckc; outbox polling still covers public posts. ## How to run @@ -96,12 +102,12 @@ config types. ## Pointers -- [Mastodon API: accounts](https://docs.joinmastodon.org/methods/accounts/), - [bookmarks](https://docs.joinmastodon.org/methods/bookmarks/), - [favourites](https://docs.joinmastodon.org/methods/favourites/), - [OAuth scopes](https://docs.joinmastodon.org/api/oauth-scopes/), - [rate limits](https://docs.joinmastodon.org/api/rate-limits/), - [pagination](https://docs.joinmastodon.org/api/guidelines/#pagination) +- [ActivityPub](https://www.w3.org/TR/activitypub/) (outbox, inbox, + delivery) and [Activity Streams 2.0](https://www.w3.org/TR/activitystreams-core/) + (collections, paging) +- [WebFinger, RFC 7033](https://www.rfc-editor.org/rfc/rfc7033) +- [Mastodon's ActivityPub notes](https://docs.joinmastodon.org/spec/activitypub/) + and [its HTTP Signatures profile](https://docs.joinmastodon.org/spec/security/) + — what social.coop actually sends and requires (the server is + Mastodon; the cache doesn't depend on it) - [#23](https://github.com/dckc/madmode-blog/issues/23) — the PESOS goal -- [webapp/README.md](webapp/README.md) — the mint form and its secret - header list