fedipod 1.43.0 → 1.45.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/README.md CHANGED
@@ -34,6 +34,16 @@ This README is about the `FediPod BrowserAgent`. There are also a number of [oth
34
34
 
35
35
  From now on, anyone can open your profile at `https://fedipod.net/@handle@provider`, signed in or not.
36
36
 
37
+ ## Getting a forum
38
+
39
+ The same page makes a forum. Choose **A forum** as the first thing on it. A
40
+ forum has a pod of its own, so make a new pod for it at your provider (or
41
+ sign in with one that holds nothing else), then give the forum a handle, a
42
+ name and its categories, and the address at fedipod.net you will moderate it
43
+ as. fedipod.net runs the forum from then on; nothing runs on your machine.
44
+ Its page is at `https://bb.fedipod.net/<handle>/` and its address is
45
+ `@handle@fedipod.net`, with one address per category.
46
+
37
47
  ## What you can do
38
48
 
39
49
  There are two clients, both served by fedipod.net:
@@ -141,6 +151,12 @@ not change.
141
151
  - [FediPod Groups](groups.md): hosting a discussion group of Fediverse and Bluesky users from a pod.
142
152
  - [FediPod Gateway](gateway.md): A thin, always-on door running on Netlify or any small host that provides a middleman between a pod and the single-user agents. (only needed with `BrowserAgent` and `DeviceAgent`)
143
153
 
154
+ There are two ways to run FediPod: in a browser at a Gateway such as
155
+ fedipod.net, with nothing installed, or as a Gateway of your own, deployed
156
+ from this repository ([netlify/README.md](netlify/README.md)). Cloning the
157
+ repository and running it is not one of them: `npm start` only says so. The
158
+ DeviceAgent is deprecated; nothing new is built for it.
159
+
144
160
 
145
161
  ## Acknowledgements
146
162
 
package/gateway.md CHANGED
@@ -15,7 +15,10 @@ yours, which your agent then signs and sends. It still holds no key, but what yo
15
15
  trust it with grows by that much. A browser account is also kept running by
16
16
  the gateway (below). That gives the gateway your key while it works, so it can
17
17
  post as you, as any Fediverse server can, and your account's working data is
18
- kept at the gateway too and written to your pod every fifteen minutes.
18
+ kept at the gateway too and written to your pod every fifteen minutes. For
19
+ a kept account the gateway also answers a follower's server asking for one
20
+ of your followers-only posts, and the named person's server asking for a
21
+ direct one, when that server signs its request; nobody else gets them.
19
22
 
20
23
  A FediPod install works without any gateway at all. Deliveries go straight to
21
24
  your pod inbox, which holds them whether your agent is running or not.
@@ -73,8 +76,9 @@ mode says how far you trust the door, and every step is reversible:
73
76
  - **locked** — your inbox accepts writes only from the gateway.
74
77
 
75
78
  In every mode past **off**, the door itself is already filtering: blocked
76
- actors, content that does not concern you, and forged signatures are dropped
77
- at the door and never reach the pod. The mode says only how far the agent
79
+ actors and content that does not concern you are dropped at the door and
80
+ never reach the pod; a delivery whose signature does not verify is passed on
81
+ marked unverified, and the agent decides by fetching what it claims to be. The mode says only how far the agent
78
82
  believes the door's receipts.
79
83
 
80
84
  The shadow numbers come from the agent:
@@ -32,11 +32,6 @@ import * as podNotes from '../pod/notes.mjs';
32
32
 
33
33
  const MAX_BODY = 512 * 1024; // same ceiling the inbox drain enforces
34
34
 
35
- // How many archived items one page of the inbox will read. A page is one
36
- // month, and a month with more than this is served short rather than costing
37
- // the pod an unbounded read; the log says when that happened.
38
- const MAX_INBOX_PAGE = 500;
39
-
40
35
  // How many recorded activities one page of the locally-served outbox carries.
41
36
  const MAX_OUTBOX_PAGE = 50;
42
37
 
@@ -99,82 +94,35 @@ export class C2S {
99
94
  return iri ? this.store.getStatuses().find((s) => s.noteId === iri) : null;
100
95
  }
101
96
 
102
- /** The months the archive holds, newest first. One container listing. */
103
- async archiveMonths() {
104
- const archive = this.agent.intake?.archive;
105
- if (!archive) return [];
106
- const { names } = await archive.list('');
107
- return (names || [])
108
- .map((n) => n.replace(/\/$/u, ''))
109
- .filter((n) => /^\d{4}-\d{2}$/u.test(n))
110
- .sort()
111
- .reverse();
112
- }
113
-
114
97
  /**
115
- * The owner's own inbox, §5.2. Paged by month because that is how the
116
- * archive is stored, so a page costs one listing and a read per item and no
117
- * page is dearer for another month being large.
98
+ * The owner's own inbox, §5.2: the collection the drain keeps on the pod in
99
+ * the private container (intake/received.mjs), served under this agent's
100
+ * address so a client holding no pod credential of its own can read it.
101
+ * Nothing is built per request; a page is the pod's page.
118
102
  */
119
- async sendInbox(res, url) {
120
- const id = `${this.urls.base}ap/inbox`;
103
+ async sendInbox(res, url, origin) {
104
+ const id = `${origin}ap/inbox`; // named for the client that asked, as the outbox is
105
+ const own = this.urls.ownInbox;
121
106
  const page = url?.searchParams?.get('page') || null;
122
- let months;
107
+ if (page !== null && !/^[1-9]\d{0,6}$/u.test(page)) return this.send(res, 400, { error: 'page is a number' });
108
+ let doc;
123
109
  try {
124
- months = await this.archiveMonths();
110
+ doc = await this.agent.remote.getJson(page ? `${own}-${page}` : own);
125
111
  } catch (e) {
126
- return this.send(res, 502, { error: `the archive could not be read: ${e.message}` });
112
+ return this.send(res, 502, { error: `the inbox collection could not be read: ${e.message}` });
127
113
  }
128
-
129
- if (!page) {
130
- if (!months.length && this.store.getConfig()?.archiveInbox === false) {
114
+ if (!doc) {
115
+ if (page) return this.send(res, 404, { error: 'no such page' });
116
+ if (this.store.getConfig()?.archiveInbox === false) {
131
117
  this.log('inbox read: nothing to show — this identity does not keep what it receives');
132
118
  }
133
- return this.send(res, 200, {
134
- '@context': wire.AS_CTX, id, type: 'OrderedCollection',
135
- ...(months.length ? { first: `${id}?page=${months[0]}` } : { orderedItems: [] }),
136
- });
137
- }
138
- if (!/^\d{4}-\d{2}$/u.test(page)) {
139
- return this.send(res, 400, { error: 'page names a month, written 2026-09' });
140
- }
141
-
142
- const archive = this.agent.intake?.archive;
143
- let names = [];
144
- try {
145
- // The trailing slash matters: without it this names a document, not the
146
- // container, and a pod answers about the wrong thing.
147
- ({ names } = await archive.list(`${page}/`));
148
- } catch (e) {
149
- return this.send(res, 502, { error: `the archive could not be read: ${e.message}` });
150
- }
151
- const files = (names || []).filter((n) => n.endsWith('.json')).sort();
152
- if (files.length > MAX_INBOX_PAGE) {
153
- this.log(`inbox read: ${page} holds ${files.length} items; serving the first ${MAX_INBOX_PAGE}`);
119
+ return this.send(res, 200, { '@context': wire.AS_CTX, id, type: 'OrderedCollection', totalItems: 0, orderedItems: [] });
154
120
  }
155
- const kept = [];
156
- for (const file of files.slice(0, MAX_INBOX_PAGE)) {
157
- // Read as written: these records are JSON-LD, and the default read asks
158
- // turtle-first, which a server is free to answer with turtle.
159
- const read = await archive.read(`${page}/${file}`, { accept: '*/*' });
160
- if (!read?.ok || !read.body) continue;
161
- try {
162
- const record = JSON.parse(read.body);
163
- // The record wraps the bytes as they arrived; the activity is those
164
- // bytes, not a retelling of them.
165
- kept.push({ at: record.receivedAt || '', activity: JSON.parse(record.raw) });
166
- } catch { /* a record that will not parse is not one that can be served */ }
167
- }
168
- kept.sort((a, b) => String(b.at).localeCompare(String(a.at)));
169
- const older = months.filter((m) => m < page)[0] || null;
170
- return this.send(res, 200, {
171
- '@context': wire.AS_CTX,
172
- id: `${id}?page=${page}`,
173
- type: 'OrderedCollectionPage',
174
- partOf: id,
175
- ...(older ? { next: `${id}?page=${older}` } : {}),
176
- orderedItems: kept.map((k) => k.activity),
177
- });
121
+ // The pod's names become this agent's: the head is `id`, page n is `id?page=n`.
122
+ const here = (v) => (typeof v === 'string' && v.startsWith(own)
123
+ ? (v === own ? id : v.replace(`${own}-`, `${id}?page=`)) : v);
124
+ for (const k of ['id', 'partOf', 'first', 'last', 'next', 'prev']) if (k in doc) doc[k] = here(doc[k]);
125
+ return this.send(res, 200, doc);
178
126
  }
179
127
 
180
128
  // The actor a client-to-server client on this machine reads. The same
@@ -277,7 +225,7 @@ export class C2S {
277
225
  }
278
226
  const reader = await this.auth(req, pathname);
279
227
  if (!reader.ok) return this.send(res, reader.status, { error: reader.error }, reader.headers || {});
280
- return this.sendInbox(res, url);
228
+ return this.sendInbox(res, url, this.localOrigin(req) || this.urls.base);
281
229
  }
282
230
  if (req.method === 'GET' || req.method === 'HEAD') {
283
231
  // The owner, signed in, reads every message the actor produced (§5.1:
@@ -28,6 +28,7 @@ import { trimActivity, sameIdentity, sameOrigin, httpUrl, reactionOf, MAX_EMOJIS
28
28
  import * as channel from './channel.mjs';
29
29
  import * as verify from './verify.mjs';
30
30
  import * as group from './group.mjs';
31
+ import * as received from './received.mjs';
31
32
  import * as activities from './activities.mjs';
32
33
  import * as notes from './notes.mjs';
33
34
  import { readLenient } from '../as2.mjs';
@@ -489,6 +490,9 @@ export class Intake {
489
490
  if (pending.length >= DELETE_BATCH && !await flush()) return;
490
491
  }
491
492
  if (!await this._finishSweep(flush)) return;
493
+ // What this sweep accepted, onto the pod as the owner's inbox collection:
494
+ // one page write and the head per sweep with mail, never per item.
495
+ await received.publishReceived(this).catch((e) => this.log(`inbox collection: ${e.message}`));
492
496
  // Receipts earlier drains left behind, a few per sweep until none remain.
493
497
  for (const stray of podInbox.orphanReceipts(this.remote, this.urls).slice(0, ORPHAN_RECEIPTS_PER_SWEEP)) {
494
498
  if (!await podInbox.dropStrayReceipt(this.remote, stray)) break;
@@ -532,7 +536,10 @@ export class Intake {
532
536
  const rejection = !activity ? 'unparsable JSON'
533
537
  : owned ? await this.ownerPostFrom(activity, raw, receipt)
534
538
  : await this.handle(activity, receipt);
535
- if (!rejection && raw && !owned) await this._archive(url, raw, activity);
539
+ if (!rejection && raw && !owned) {
540
+ await this._archive(url, raw, activity);
541
+ received.recordReceived(this, activity); // §5.2: the owner's readable inbox
542
+ }
536
543
  if (!rejection && !owned) await this._maybeForward(activity); // §7.1.2, only what we accepted
537
544
  if (rejection) {
538
545
  this.store.addDeadLetter({
@@ -0,0 +1,78 @@
1
+ // received.mjs — the inbox as its owner reads it (ActivityPub §5.2): every
2
+ // delivery the account accepted, kept on the pod as a paged OrderedCollection
3
+ // in the private container, beside the archive that keeps each delivery's
4
+ // bytes. The door and the agent's own /ap/inbox serve these documents.
5
+ //
6
+ // Pages fill from the oldest end, PAGE_SIZE to a page, and a full page is
7
+ // never written again: an accepted delivery rewrites the newest page and the
8
+ // small head, and nothing else. Activities ride inline, so a reader gets what
9
+ // arrived without a fetch per item — a Like or a Follow has no address its
10
+ // origin answers anyway. Past ITEM_MAX_CHARS the item is the activity's id
11
+ // alone, so one enormous delivery cannot make a page of them enormous.
12
+ //
13
+ // §5.2 also says an activity delivered twice is listed once. The archive
14
+ // already collapses identical bytes; this keeps the last SEEN_MAX ids and
15
+ // drops a repeat that arrived with different addressing.
16
+ //
17
+ // Written only while the archive is on — an owner who chose not to keep what
18
+ // arrives keeps nothing here either — and only once the private container is
19
+ // proved private, the bar every owner-only document clears. Recorded meanwhile,
20
+ // and published the first time the bar is met.
21
+
22
+ import * as wire from '../wire.mjs';
23
+ import * as collection from '../../pod/collection.mjs';
24
+
25
+ export const PAGE_SIZE = wire.FOLLOWERS_PAGE_SIZE;
26
+ export const SEEN_MAX = 500;
27
+ export const ITEM_MAX_CHARS = 32 * 1024;
28
+ // Recorded but unpublished items are held in state; past this many the oldest
29
+ // are let go (the archive still has them) rather than growing state forever.
30
+ const HELD_MAX = PAGE_SIZE * 5;
31
+ const RECEIVED = 'received.json';
32
+
33
+ const fresh = () => ({ sealed: 0, open: [], total: 0, seen: [] });
34
+
35
+ const keeps = (intake) => !!intake.archive && !!intake.urls?.ownInbox
36
+ && intake.store.getConfig?.()?.archiveInbox !== false;
37
+
38
+ /** Record one accepted delivery. False when it was a repeat, or nothing is kept. */
39
+ export function recordReceived(intake, activity) {
40
+ if (!keeps(intake) || !activity || typeof activity !== 'object') return false;
41
+ const st = { ...fresh(), ...intake.store.read(RECEIVED, {}) };
42
+ const id = typeof activity.id === 'string' ? activity.id : null;
43
+ if (id && st.seen.includes(id)) return false;
44
+ st.open.push(id && JSON.stringify(activity).length > ITEM_MAX_CHARS ? id : activity);
45
+ if (st.open.length > HELD_MAX) st.open.splice(0, st.open.length - HELD_MAX);
46
+ st.total += 1;
47
+ if (id) {
48
+ st.seen.push(id);
49
+ if (st.seen.length > SEEN_MAX) st.seen.splice(0, st.seen.length - SEEN_MAX);
50
+ }
51
+ intake.store.write(RECEIVED, st);
52
+ intake._receivedDirty = true;
53
+ return true;
54
+ }
55
+
56
+ /** Write what was recorded since the last publish: the newest page(s) and the head. */
57
+ export async function publishReceived(intake) {
58
+ if (!intake._receivedDirty || !keeps(intake)) return;
59
+ const ready = intake.publisher?.privateReady;
60
+ if (typeof ready === 'function' && await ready.call(intake.publisher) !== true) return;
61
+ const { remote, urls } = intake;
62
+ const st = { ...fresh(), ...intake.store.read(RECEIVED, {}) };
63
+ // Every full page first (several when the bar was met late), then the open one.
64
+ do {
65
+ const n = st.sealed + 1;
66
+ const items = st.open.slice(0, PAGE_SIZE);
67
+ await collection.writePage(remote, wire.followersPageId(urls.ownInbox, n),
68
+ wire.followersPage(urls.ownInbox, n, items, n));
69
+ if (items.length < PAGE_SIZE) break;
70
+ st.sealed = n;
71
+ st.open = st.open.slice(PAGE_SIZE);
72
+ intake.store.write(RECEIVED, st);
73
+ } while (st.open.length);
74
+ const pages = Math.max(1, st.sealed + (st.open.length ? 1 : 0));
75
+ await collection.writeHead(remote, urls.ownInbox, wire.followersHead(urls.ownInbox, st.total, pages));
76
+ intake.store.write(RECEIVED, st);
77
+ intake._receivedDirty = false;
78
+ }
@@ -28,6 +28,7 @@ import { routeNoticesApi } from './notices.mjs';
28
28
  import { withSecurityHeaders } from './headers.mjs';
29
29
  import { podTokenVerifier } from './caches.mjs';
30
30
  import { claimedWebId } from './token-claims.mjs';
31
+ import { answerPrivateRead } from './private-read.mjs';
31
32
  import { announced, overLimit, relayOne } from './relay-extras.mjs';
32
33
 
33
34
  // The one WebFinger document, spelled out here rather than imported from
@@ -740,11 +741,6 @@ async function route(request, ctx) {
740
741
  return { status: 200, headers: { 'content-type': 'text/javascript' }, body: ctx.authBundle };
741
742
  }
742
743
 
743
- // The installer: curl -fsSL https://<host>/install | sh
744
- if (pathname === '/install') {
745
- if (!ctx.installScript) return notFound();
746
- return { status: 200, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: ctx.installScript };
747
- }
748
744
 
749
745
  // WebFinger: acct:<handle>@<host> → the fronted actor. This is what makes
750
746
  // @name@fedipod.net a real handle the fediverse can look up.
@@ -804,18 +800,36 @@ async function route(request, ctx) {
804
800
  const CLOSED = 'this address is closed';
805
801
 
806
802
  // Inbox: verify at the door, forward to the user's pod inbox. This is the
807
- // gateway, per user.
803
+ // gateway, per user. A forum's own page posts here from its own host, so the
804
+ // door answers a browser's preflight and carries the open origin; a delivery
805
+ // needs no credential, and a browser sends none across origins.
808
806
  if (up.rest === 'ap/inbox/' || up.rest === 'ap/inbox') {
807
+ const doorCors = {
808
+ 'access-control-allow-origin': '*',
809
+ 'access-control-allow-methods': 'POST, OPTIONS',
810
+ 'access-control-allow-headers': 'Content-Type',
811
+ 'access-control-max-age': '86400',
812
+ };
813
+ if (request.method === 'OPTIONS') return { status: 204, headers: { ...doorCors, allow: 'POST, OPTIONS' }, body: null };
814
+ // The owner, signed in, reads the inbox as a collection (§5.2): the drain
815
+ // keeps it on the pod, under the owner's own rule. The token is only
816
+ // read, never verified, as at the outbox door: the pod checks it.
817
+ if ((request.method === 'GET' || request.method === 'HEAD') && request.headers.get('authorization')) {
818
+ const webid = claimedWebId(request);
819
+ if (webid && (rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome))) {
820
+ return { status: 303, headers: { ...doorCors, location: rec.podHome + 'ap/private/inbox', 'cache-control': 'no-store' }, body: '' };
821
+ }
822
+ }
809
823
  if (request.method !== 'POST') return { status: 405, headers: {}, body: '' };
810
- if (movedBase) { console.log(`door @${up.handle}: delivery → 410 (moved)`); return gone(); }
811
- if (closed) { console.log(`door @${up.handle}: delivery → 410 (closed)`); return gone({}, CLOSED); }
824
+ if (movedBase) { console.log(`door @${up.handle}: delivery → 410 (moved)`); return gone(doorCors); }
825
+ if (closed) { console.log(`door @${up.handle}: delivery → 410 (closed)`); return gone(doorCors, CLOSED); }
812
826
  const policy = await policyFor(rec, ctx.fetchImpl || fetch);
813
827
  const standing = await accountState(ctx, up.handle, rec);
814
828
  // A browser account's mail waits here while its app is closed (held-mail.mjs).
815
829
  const held = holdsMail(ctx, rec) && !(await isPresent(ctx, up.handle));
816
830
  const { status, reason, content, bytes, type, notifies, name, raw, receipt } = await handleDelivery(request, identFor(rec, policy),
817
831
  { podPut: held ? holdingPut(ctx, up.handle, rec) : (u, b, ct) => ctx.podPut(up.handle, u, b, ct),
818
- fetchImpl: ctx.fetchImpl, paused: standing.paused });
832
+ fetchImpl: ctx.fetchImpl, paused: standing.paused, origin: ctx.frontOrigin || null });
819
833
  // One line per delivery, so "did it arrive at the door" has an answer.
820
834
  console.log(`door @${up.handle}: delivery → ${status} (${reason}${held && status === 202 ? ', held' : ''})`);
821
835
  if (status === 202 && content) await noteReceived(ctx, up.handle, rec, bytes);
@@ -827,9 +841,15 @@ async function route(request, ctx) {
827
841
  const placed = await ctx.forum.placeAtDoor(ctx, up.handle, rec, { name, raw, receipt })
828
842
  .catch((e) => ({ placed: false, why: e.message }));
829
843
  console.log(`door @${up.handle}: ${placed.placed ? 'placed' : `not placed (${placed.why})`}`);
844
+ // Placed means written on the pod: the edge's copies of the category's
845
+ // and the forum's documents go, so the page that posted reads it back.
846
+ if (placed.placed) {
847
+ const tags = [`u-${up.handle}`, ...(placed.forum && placed.forum !== up.handle ? [`u-${placed.forum}`] : [])];
848
+ await ctx.purge?.(tags).catch((e) => console.log(`purge @${up.handle}: ${e?.message || e}`));
849
+ }
830
850
  if (placed.forum && ctx.startKeeper) await ctx.startKeeper(placed.forum).catch((e) => console.log(`keeper @${placed.forum}: not started (${e.message})`));
831
851
  }
832
- return { status, headers: {}, body: '' };
852
+ return { status, headers: doorCors, body: '' };
833
853
  }
834
854
 
835
855
  // Outbox: the owner's own post, from any client that speaks ActivityPub
@@ -901,10 +921,9 @@ async function route(request, ctx) {
901
921
  const podTarget = rec.podHome + up.rest;
902
922
  // The owner's own documents — the full outbox, the liked list, the pending
903
923
  // lists — are read at the pod, which decides who may; never held here,
904
- // where one reader's answer would be served to the next.
905
- if (up.rest.startsWith('ap/private/')) {
906
- return { status: 303, headers: { ...open, location: podTarget, 'cache-control': 'no-store' }, body: '' };
907
- }
924
+ // where one reader's answer would be served to the next. A signed read of
925
+ // a private post by a server it was sent to is answered (private-read.mjs).
926
+ if (up.rest.startsWith('ap/private/')) return answerPrivateRead(ctx, rec, { request, handle: up.handle, podTarget, base, open, swap });
908
927
  // Media stays on the pod (lib/pod/urls.mjs keeps `media` off the front), but
909
928
  // the id rewrite below turns media links onto the front like every other
910
929
  // pod url in a document. Answer those by pointing at the pod: bytes are not
@@ -1,8 +1,7 @@
1
1
  // front-pages.mjs — the front's pages and the files they load, read from
2
2
  // beside this package: the opt-in page, the roster, the notices page, the
3
- // new-account page, each page's own script, the sign-in library and the
4
- // installer. A file that is not there comes back null, and the route it
5
- // feeds answers 404.
3
+ // root page, each page's own script and the sign-in library. A file that is
4
+ // not there comes back null, and the route it feeds answers 404.
6
5
 
7
6
  import fs from 'node:fs';
8
7
  import path from 'node:path';
@@ -26,6 +25,5 @@ export function frontPages() {
26
25
  noticesPage: readOrNull('notices.html'),
27
26
  authBundle: readOrNull('solid-oidc-client.js'),
28
27
  pageScripts,
29
- installScript: readOrNull('install.sh'),
30
28
  };
31
29
  }
@@ -91,12 +91,34 @@ export function notifies(activity, ident) {
91
91
  // Returns { status, reason } — the adapter turns it into an HTTP response —
92
92
  // and, for a delivery that reached the pod, `content` (whether it was
93
93
  // content rather than control) and `bytes`, so the caller can keep count.
94
- export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES, paused = false } = {}) {
95
- // Read the body once from a clone; the original, unconsumed, goes to the
96
- // verifier (which needs the body for the Digest check).
97
- let raw;
98
- try { raw = await request.clone().text(); } catch { return { status: 400, reason: 'unreadable body' }; }
99
- if (Buffer.byteLength(raw) > maxBytes) return { status: 413, reason: 'too large' };
94
+ // The request as the sender signed it. A sender signs the host it delivered
95
+ // to and digests the bytes it sent; what reaches this code has been through
96
+ // an edge that may have rewritten the Host header, the URL's host or both
97
+ // (Netlify does), and a verifier that trusts those fails every honest
98
+ // delivery. So the verifier gets a request rebuilt from the bytes read here,
99
+ // at the door's own origin when the caller names it, else at the host a
100
+ // proxy says it forwarded for, else at the URL's own.
101
+ export function asSigned(request, bytes, origin) {
102
+ const u = new URL(request.url);
103
+ let host = null;
104
+ if (origin) { try { const o = new URL(origin); u.protocol = o.protocol; host = o.host; } catch { /* not an origin: the URL's own */ } }
105
+ host = host || request.headers.get('x-forwarded-host')?.split(',')[0].trim() || u.host;
106
+ u.host = host;
107
+ const headers = new Headers(request.headers);
108
+ headers.set('host', host);
109
+ // A GET or HEAD carries no body, and a Request refuses one (a signed read: private-read.mjs).
110
+ const bodiless = request.method === 'GET' || request.method === 'HEAD';
111
+ return new Request(u.href, { method: request.method, headers, ...(bodiless ? {} : { body: bytes }) });
112
+ }
113
+
114
+ // `origin` is the door's own, the one senders address (see asSigned).
115
+ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES, paused = false, origin = null } = {}) {
116
+ // The body, once, as the bytes the sender digested; the text of it is what
117
+ // is filed and hashed, as before.
118
+ let bytes, raw;
119
+ try { bytes = new Uint8Array(await request.clone().arrayBuffer()); raw = new TextDecoder().decode(bytes); }
120
+ catch { return { status: 400, reason: 'unreadable body' }; }
121
+ if (bytes.byteLength > maxBytes) return { status: 413, reason: 'too large' };
100
122
 
101
123
  let activity;
102
124
  try { activity = JSON.parse(raw); } catch { return { status: 400, reason: 'unparsable JSON' }; }
@@ -114,15 +136,25 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
114
136
 
115
137
  // The sender's key, kept between deliveries: a server pushing a hundred
116
138
  // items is asked for its key once (caches.mjs).
117
- const v = await verifyHttpSignature(request, {
139
+ const v = await verifyHttpSignature(asSigned(request, bytes, origin), {
118
140
  documentLoader: makeSafeLoader({ fetchImpl }), keyCache: senderKeys,
119
141
  });
120
- // A present-but-invalid signature is a forgery — dropped here, so it never
121
- // reaches the pod (today it would, drain, and die unapplied). An absent or
122
- // unfetchable-key signature is NOT dropped: it forwards unverified and the
123
- // drain's verify-by-dereference still stands behind it.
142
+ // A signature that does not verify is NOT dropped (it was, 2026-08-20 to
143
+ // 2026-09-28, and for a stretch of that every honest Mastodon delivery was
144
+ // dropped with it, silently, because of what the edge had done to the
145
+ // request). It forwards unverified, like an absent or unfetchable-key
146
+ // signature, and the drain's verify-by-dereference decides; the log says
147
+ // why it failed and what the request looked like, so the next such
148
+ // stretch is visible.
149
+ let failed = null;
124
150
  if (v.verified === false && v.reason === 'bad-signature') {
125
- return { status: 202, reason: 'forged signature' };
151
+ const has = (h) => (request.headers.has(h) ? 'yes' : 'no');
152
+ const fwd = request.headers.get('x-forwarded-host');
153
+ // The sender's signature parameters — which components it signed, its
154
+ // key id and algorithm — without the signature value itself.
155
+ const params = request.headers.get('signature-input')
156
+ || (request.headers.get('signature') || '').replace(/signature="[^"]*"/u, 'signature="…"');
157
+ failed = `signature ${v.why} (${v.method}); host ${request.headers.get('host') || 'none'}${fwd ? ` forwarded-for ${fwd}` : ''}, date ${has('date')}, digest ${has('digest') === 'yes' || has('content-digest') === 'yes' ? 'yes' : 'no'}; ${params.slice(0, 300)}`;
126
158
  }
127
159
 
128
160
  // No per-user secret on record = no receipt: the delivery still filters and
@@ -137,8 +169,8 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
137
169
  // holding any state.
138
170
  if (!okA) return { status: 502, reason: 'pod inbox write failed' };
139
171
  if (receipt) await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
140
- return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified', content, bytes: Buffer.byteLength(raw),
141
- type: activity.type, notifies: notifies(activity, ident), name: hash, raw, receipt };
172
+ return { status: 202, reason: v.verified ? 'verified' : failed ? `buffered-unverified: ${failed}` : 'buffered-unverified',
173
+ content, bytes: bytes.byteLength, type: activity.type, notifies: notifies(activity, ident), name: hash, raw, receipt };
142
174
  }
143
175
 
144
176
  // The outbox door: the owner's own post, taken on their behalf.
@@ -171,6 +171,13 @@ export async function routeKeeperApi(request, pathname, ctx, deps) {
171
171
  if (typeof body.forum === 'string' && /^[a-z0-9][a-z0-9._-]{0,60}$/u.test(body.forum)) next.forum = body.forum;
172
172
  await ctx.putDirectory(handle, next);
173
173
  console.log(`keeper @${handle}: ${on ? 'on' : 'off'}, at the owner's word`);
174
+ // A forum's own row turned on starts its first run now: the run makes the
175
+ // copies, mints the keys a new forum has none of, and publishes its actors,
176
+ // so there is a forum to read before anyone posts to it. Its categories'
177
+ // rows are turned on before it (setup.mjs in the forum package).
178
+ if (on && next.kind === 'application' && ctx.forum?.isForumRow(next) && ctx.startKeeper) {
179
+ await ctx.startKeeper(handle).catch((e) => console.log(`keeper @${handle}: not started (${e.message})`));
180
+ }
174
181
  return j(200, { ok: true, handle, kept: on, keeper: ctx.keeperWebId || null });
175
182
  }
176
183
 
@@ -49,14 +49,16 @@ export function makeSafeLoader({ getActors = null, fetchImpl = fetch } = {}) {
49
49
  export async function verifyHttpSignature(request, { documentLoader, keyCache, timeWindow } = {}) {
50
50
  const hadSig = request.headers.get('signature') != null;
51
51
  let result = null;
52
+ let threw = null;
52
53
  try {
53
54
  result = await verifyRequestDetailed(request, {
54
55
  documentLoader,
55
56
  ...(keyCache ? { keyCache } : {}),
56
57
  timeWindow: timeWindow ?? { hours: 1 },
57
58
  });
58
- } catch {
59
+ } catch (e) {
59
60
  result = null; // the library itself failed — no more informative than a bad signature
61
+ threw = e?.message || String(e);
60
62
  }
61
63
  // Which signature the sender used: RFC 9421 carries a Signature-Input
62
64
  // header beside Signature; the cavage draft carries Signature alone. The
@@ -83,9 +85,12 @@ export async function verifyHttpSignature(request, { documentLoader, keyCache, t
83
85
  reason: 'key-unfetchable', checks: { signature: false, keyFetched: false },
84
86
  };
85
87
  }
88
+ // `why` is for the log line, not the receipt: the library's own word for
89
+ // the failure, or that it threw.
86
90
  return {
87
91
  verified: false, method, keyId: null, actor: null,
88
92
  reason: 'bad-signature', checks: { signature: false },
93
+ why: threw ? `library threw: ${threw.split('\n')[0].slice(0, 120)}` : (kind || 'unknown'),
89
94
  };
90
95
  }
91
96
 
@@ -0,0 +1,91 @@
1
+ // private-read.mjs — a followers-only or direct post, read at its address by
2
+ // a server it was sent to (ActivityPub §3.2). The read is signed the way a
3
+ // delivery is; the signer is resolved; and the post is served only to an
4
+ // actor in its audience: a follower for a post sent to followers, a named
5
+ // recipient for a direct one. That is the rule Mastodon applies to its own
6
+ // posts (StatusPolicy#show?). Anyone else learns nothing — 404, as for a
7
+ // document that is not there, which §3.2 allows for a private target.
8
+ //
9
+ // At fedipod.net this works for an account it keeps: the post is read from
10
+ // the pod with the keeper's credential, and the followers list from the
11
+ // account's copy. An account it does not keep gives it nothing to read with,
12
+ // so that read is sent on to the pod, as every private read was.
13
+
14
+ import { verifyHttpSignature, makeSafeLoader } from './httpsig.mjs';
15
+ import { asSigned } from './gateway-core.mjs';
16
+ import { senderKeys } from './caches.mjs';
17
+ import { keptNow } from './copy.mjs';
18
+
19
+ const AP_CT = 'application/activity+json';
20
+ const PUBLIC_NAMES = new Set(['https://www.w3.org/ns/activitystreams#Public', 'as:Public', 'Public']);
21
+ const list = (v) => (v == null ? [] : Array.isArray(v) ? v : [v])
22
+ .map((x) => (typeof x === 'string' ? x : x?.id)).filter((x) => typeof x === 'string');
23
+
24
+ /** Who a document was sent to: the actors named, and whether followers were. */
25
+ export function audienceOf(doc, followersUrls = []) {
26
+ const o = doc?.type === 'Create' && doc.object && typeof doc.object === 'object' ? doc.object : doc;
27
+ const named = new Set(['to', 'cc', 'audience'].flatMap((k) => [...list(doc?.[k]), ...list(o?.[k])]));
28
+ return {
29
+ named,
30
+ isPublic: [...named].some((a) => PUBLIC_NAMES.has(a)),
31
+ toFollowers: followersUrls.some((f) => named.has(f)),
32
+ };
33
+ }
34
+
35
+ /** Whether `actor` may read `doc`: named in its audience, or a follower when it went to followers. */
36
+ export function mayRead(doc, actor, { followersUrls = [], followers = [] } = {}) {
37
+ if (typeof actor !== 'string' || !actor) return false;
38
+ const a = audienceOf(doc, followersUrls);
39
+ if (a.isPublic || a.named.has(actor)) return true;
40
+ return a.toFollowers && followers.includes(actor);
41
+ }
42
+
43
+ /**
44
+ * The actor behind a signed read, or null with why. `origin` is the door's
45
+ * own (see asSigned in gateway-core.mjs); a GET has no body to digest.
46
+ */
47
+ export async function signedReader(request, { fetchImpl = fetch, origin = null } = {}) {
48
+ if (!request.headers.get('signature')) return { actor: null, reason: 'no-signature' };
49
+ const v = await verifyHttpSignature(asSigned(request, new Uint8Array(0), origin), {
50
+ documentLoader: makeSafeLoader({ fetchImpl }), keyCache: senderKeys,
51
+ });
52
+ return v.verified && typeof v.actor === 'string' ? { actor: v.actor, reason: null } : { actor: null, reason: v.reason };
53
+ }
54
+
55
+ // Refused reads are told nothing, and never held at the edge.
56
+ const nothing = (open) => ({ status: 404, headers: { ...open, 'cache-control': 'no-store', vary: 'Signature' }, body: '' });
57
+
58
+ /**
59
+ * A read under `ap/private/` at the front. Unsigned: sent on to the pod, which
60
+ * decides (the owner reads there with their own credential). Signed: answered
61
+ * here for a kept account, to a reader the post was sent to.
62
+ */
63
+ export async function answerPrivateRead(ctx, rec, { request, handle, podTarget, base, open, swap }) {
64
+ if (!request.headers.get('signature')) {
65
+ return { status: 303, headers: { ...open, location: podTarget, 'cache-control': 'no-store' }, body: '' };
66
+ }
67
+ const say = (what) => console.log(`private read @${handle}: ${what}`);
68
+ if (!keptNow(ctx, rec) || typeof ctx.keeperFetch !== 'function' || !ctx.copyKv) { say('refused — account not kept here'); return nothing(open); }
69
+ const { actor, reason } = await signedReader(request, { fetchImpl: ctx.fetchImpl || fetch, origin: ctx.frontOrigin || null });
70
+ if (!actor) { say(`refused — ${reason}`); return nothing(open); }
71
+ const podFetch = await ctx.keeperFetch();
72
+ if (!podFetch) { say('refused — no keeper credential'); return nothing(open); }
73
+ let text = null;
74
+ try {
75
+ const res = await podFetch(podTarget, { headers: { accept: AP_CT } });
76
+ if (res.status === 200) text = await res.text();
77
+ } catch { /* unreachable: nothing to serve */ }
78
+ let doc = null;
79
+ try { doc = text ? JSON.parse(text) : null; } catch { doc = null; }
80
+ if (!doc) { say(`refused — the pod did not give it (${actor})`); return nothing(open); }
81
+ let followers = [];
82
+ try {
83
+ const got = await ctx.copyKv.get(`${handle}/contacts.json`);
84
+ followers = (JSON.parse(got?.text || '{}').followers || []).map((f) => f?.actor).filter(Boolean);
85
+ } catch { followers = []; }
86
+ const followersUrls = [rec.podHome + 'ap/followers', base + 'ap/followers'];
87
+ if (!mayRead(doc, actor, { followersUrls, followers })) { say(`refused — ${actor} is not in its audience`); return nothing(open); }
88
+ say(`served to ${actor}`);
89
+ return { status: 200, headers: { ...open, 'content-type': AP_CT, 'cache-control': 'no-store', vary: 'Signature' },
90
+ body: request.method === 'HEAD' ? '' : swap(text, rec.podHome, base) };
91
+ }
package/lib/pod/urls.mjs CHANGED
@@ -52,6 +52,9 @@ export function apUrls(remotePod, root, { publicBase = null } = {}) {
52
52
  // reads them there, with a credential the pod checks.
53
53
  ownOutbox: home + 'ap/private/outbox',
54
54
  liked: home + 'ap/private/liked',
55
+ // The inbox as its owner reads it (§5.2): what the account received,
56
+ // kept by the drain as a paged collection beside the archive.
57
+ ownInbox: home + 'ap/private/inbox',
55
58
  profileHtml: face + 'ap/profile.html',
56
59
  // Media stays on the pod even when fronted: attachment urls are not
57
60
  // identity-checked by remotes, and proxying blobs would be pure cost.