A Cloudflare Worker that projects Divine's (Nostr) videos into the fediverse.
Divine users appear as @user@divine.video ActivityPub actors, so Mastodon,
Pixelfed, and Loops can follow them and receive their videos. Nostr stays the
source of truth — this Worker is a moderated read-projection plus inbox
gateway. It hosts NO video; attachment URLs point at Divine's existing CDN.
For the full design see RESEARCH.md (decision/architecture),
wire-format.md (exact actor/Note/Document shapes), and
PLAN.md (phases, data model, acceptance). This is Workstream C
of agent-prompts.md.
Early / MVP, but live. Phase 2 (read-only projection) and Phase 3 (follow +
delivery) are implemented and running behind divine.video/ap/*. Some pieces are
provisional:
- Signing runs in
singlemode in the deployed config (SIGNER_MODE = "single"inwrangler.toml): one RSA keypair, from env, shared by every actor. The blast radius is every user, so this is an MVP-only setting — production must move tokeycast. The in-code default whenSIGNER_MODEis unset islocal. - The delivery Queue consumer is temporarily attached to the staging worker,
so the
[[queues.consumers]]block inwrangler.tomlis commented out here. The producer (cron → enqueue) is live; the consumer signs and POSTs. - Inbound
Like/Announce/Reply/Create→ Nostr mapping, CSAM hash-matching, and instance blocklists are not yet built (see below).
GET /ap/users/{username}→ AS2Personactor (wire-format.md §1). Profile from FunnelCake;publicKeyPemfrom the active signer;endpoints.sharedInbox.GET /ap/users/{username}/outbox→OrderedCollectionofCreate{Note}, each with aDocumentvideo attachment (§2–4), moderation-gated, capped atOUTBOX_MAX_ITEMS.GET /ap/users/{username}/followersand/following→ minimal collections (counts only; items omitted by design).POST /ap/users/{username}/inboxand sharedPOST /ap/inbox:- Verify inbound HTTP Signature (draft-cavage, RSASSA-PKCS1-v1_5 + SHA-256)
and
Digest; dedup viainbox_seen. Follow→ store follower in D1 and send a signedAccept.Undo{Follow}→ remove follower.
- Verify inbound HTTP Signature (draft-cavage, RSASSA-PKCS1-v1_5 + SHA-256)
and
- Cron (
scheduled, every 5 min) polls FunnelCakeGET /api/v2/videos?sort=recent, gates each new video, and enqueues one Queue message per unique follower inbox (dedup bysharedInbox). The Queue consumer builds theCreate{Note}, signs it, and POSTs it — relying on Queue retries for failures. Deliveries are NEVER fanned out inline (subrequest/CPU caps). GET /.well-known/webfinger— makes this host self-discoverable (e.g. on a workers.dev host) so a Mastodon@user@<host>lookup resolves without the name-server. Resolves the username via NIP-05; 404 if unknown.GET /health→ liveness JSON.GET /ap/debug/probeandPOST /ap/debug/backfillare token-gated operational endpoints (requireDEBUG_TOKEN) for diagnosing FunnelCake reachability and re-running a follower's catalogue backfill.
Reads require an ActivityStreams Accept header; a browser request to a
/ap/users/{username} path is redirected (302) to the HTML profile.
- Inbound
Like/Announce/Reply/Create→ Nostr mapping (needs surrogate identities + custodial keys). Seesrc/inbox.mjsclassifyActivity. - CSAM hash-matching (PhotoDNA/NCMEC) — a pre-launch requirement.
- Instance blocklist subscriptions and
Flagabuse-report handling. - NodeInfo lives in
divine-name-server(Workstream A). WebFinger is served there fordivine.video; this Worker also serves its own WebFinger so a standalone host stays discoverable.
Nostr is authoritative. This gateway is a one-way read-projection of Divine's video events into ActivityStreams 2.0, plus an inbox for follow bookkeeping:
Fediverse (Mastodon / Pixelfed / Loops)
│ Follow / Undo ▲ Create{Note} + Document
▼ │
┌─────────────────────────────────────────────┐
│ divine-activity-pub (Cloudflare Worker) │
│ fetch: actor / outbox / inbox / wf │
│ scheduled: poll firehose → enqueue │
│ queue: sign + POST each delivery │
└─────────────────────────────────────────────┘
│ reads │ verdicts │ sign / keys
▼ ▼ ▼
FunnelCake moderation-api signer (keycast / single / local)
(videos+profiles) (federation gate)
▲
│ username → pubkey (NIP-05)
divine-name-server (router / name KV)
- Read path: the actor and outbox are built from FunnelCake profile and
video data, normalized to AS2 by the pure builders in
src/as2.mjs. Every video is checked against moderation before it is federated (verdictAllowsFederation); blocked/quarantined items are dropped. - Follow path: inbound activities land on the inbox, are signature-verified,
classified (
src/inbox.mjs), andFollow/Undo{Follow}update the D1followerstable. A signedAcceptis returned for eachFollow. - Delivery path: the cron trigger polls the new-video firehose, gates it,
and produces one Queue message per unique follower inbox. The Queue consumer
signs and POSTs each
Create{Note}; transient failures rely on Queue retries. - Identity resolution: the router that fronts
divine.videoowns the name KV, resolvesusername → pubkey, and passes it asX-Nostr-Pubkeyso the gateway can warm its actor cache for users it has never seen (a same-zone NIP-05 subrequest from inside thedivine.videozone would otherwise fail).
Actors are served same-origin as WebFinger (the /ap/* prefix on divine.video
routes to this Worker) to satisfy Mastodon's bidirectional WebFinger check — see
RESEARCH.md.
| File | Purpose |
|---|---|
src/index.mjs |
Worker entry: fetch routes, scheduled cron, queue consumer; wires env → clients → pure builders. |
src/as2.mjs |
Pure AS2 builders: buildActor, buildNote, buildCreate, buildOutbox, actorUrls, contexts. |
src/http-signature.mjs |
draft-cavage signatures: one shared buildSigningString, verifySignature (WebCrypto), buildSignedRequest. |
src/keycast.mjs |
KeycastClient interface + real HTTP impl + in-memory WebCrypto fake (tests). |
src/signer-local.mjs |
LocalKeycastClient — same interface, mints+stores per-actor RSA keys in D1 local_keys and signs locally. Dev/staging only (SIGNER_MODE=local). |
src/signer-single.mjs |
SingleKeySigner — same interface, ONE RSA keypair (from env) shared by all actors. MVP only (SIGNER_MODE=single); blast radius = every user. |
src/webfinger.mjs |
WebFinger (RFC 7033) on the gateway host: parseAcctResource, buildWebfingerJrd, handleWebfinger (resolves via NIP-05; 404 if unknown). |
src/clients.mjs |
FunnelCake / moderation / name-server (NIP-05) clients + verdictAllowsFederation gate. |
src/inbox.mjs |
Pure inbound helpers: classifyActivity, buildAccept, extractFollowerEndpoints. |
src/delivery.mjs |
buildDeliveryMessages (one-per-unique-inbox) + deliverMessage (sign+POST). |
src/db.mjs |
D1 access (actors/followers/objects/inbox_seen/local_keys) + initSchema. |
migrations/001-initial.sql, 002-local-keys.sql |
D1 schema (mirrored by initSchema). |
npm install
npm test # vitest run (Workers pool; local D1/Queues; no network)
npm run dev # wrangler dev
npm run tail # wrangler tail (stream logs from the deployed worker)Tests inject fakes for every client (FunnelCake / moderation / signer / fetch), so no test touches the network or a real inbox. The signer fake generates a real RSA-2048 key via WebCrypto and signs locally.
To run the gateway locally without keycast or production dependencies, see
SETUP.md. The default signer (SIGNER_MODE=local) mints per-actor
keys in D1, so the gateway runs end-to-end with no external signing service.
Bindings (wrangler.toml):
| Binding | Type | Use |
|---|---|---|
AP_DB |
D1 | gateway state (actors/followers/objects/inbox_seen/local_keys) |
AP_CACHE |
KV | optional short-lived caches (username↔pubkey, actor docs) |
DELIVERY_QUEUE |
Queue (producer) | one message per follower inbox |
ap-delivery-queue |
Queue (consumer) | signs + POSTs each Create{Note} — currently attached to the staging worker; the consumer block is commented out here |
cron */5 * * * * |
trigger | poll firehose → enqueue deliveries |
Signer selection (SIGNER_MODE):
local—LocalKeycastClient; mints + stores per-actor RSA keys in D1 and signs locally. Runs without keycast. Dev/staging only. This is the in-code default whenSIGNER_MODEis unset.single—SingleKeySigner; ONE RSA keypair shared by all actors, from env (SINGLE_SIGNING_KEY_PKCS8secret +SINGLE_SIGNING_PUBLIC_PEMvar). MVP only — blast radius = every user. This is the value in the currentwrangler.toml.keycast— real HTTP keycast; keys custodied remotely, gateway holds none. Required in production.
Vars: AP_DOMAIN, FUNNELCAKE_BASE_URL, FUNNELCAKE_FALLBACK_URL,
MODERATION_BASE_URL, NAME_SERVER_BASE_URL, KEYCAST_BASE_URL,
DELIVERY_POLL_LIMIT, OUTBOX_MAX_ITEMS, SIGNER_MODE, and (for single)
SINGLE_SIGNING_PUBLIC_PEM. DEBUG_TOKEN gates the /ap/debug/* endpoints.
Secrets (wrangler secret put <NAME>): KEYCAST_API_TOKEN (for keycast mode);
SINGLE_SIGNING_KEY_PKCS8 (for single mode — RSA private key, PKCS8 DER as
base64, body only). See src/signer-single.mjs / SETUP.md for the exact
generate command.
- keycast (Workstream B):
GET /api/ap/keys/{actor}→{ publicKeyPem };POST /api/ap/sign {actor, signing_string}→{ signature }(base64 RSA-SHA256). - name-server (Workstream A): NIP-05
GET /.well-known/nostr.json?name={user}→ username→pubkey; plus WebFinger/NodeInfo (built there). - FunnelCake:
GET /api/users/{pubkey}(profile),GET /api/v2/users/{pubkey}/videos(per-actor catalogue),GET /api/v2/videos?sort=recent(new-video firehose). - moderation:
GET /check-result/{sha256}→{ moderated, blocked, quarantined, action }.
The Worker deploys to Cloudflare with wrangler. Before the first deploy, create
the real D1 database, Queue, and KV namespace and paste their ids into
wrangler.toml:
wrangler d1 create divine-activity-pub
wrangler queues create ap-delivery-queue
wrangler d1 migrations apply divine-activity-pub
npm run deploy # do NOT deploy from CI without the real bindingsProduction must set SIGNER_MODE = "keycast" and provide KEYCAST_API_TOKEN.
Part of Divine — your playground for human creativity · Brand guidelines