fedipod 1.44.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/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
@@ -810,6 +811,15 @@ async function route(request, ctx) {
810
811
  'access-control-max-age': '86400',
811
812
  };
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
+ }
813
823
  if (request.method !== 'POST') return { status: 405, headers: {}, body: '' };
814
824
  if (movedBase) { console.log(`door @${up.handle}: delivery → 410 (moved)`); return gone(doorCors); }
815
825
  if (closed) { console.log(`door @${up.handle}: delivery → 410 (closed)`); return gone(doorCors, CLOSED); }
@@ -819,7 +829,7 @@ async function route(request, ctx) {
819
829
  const held = holdsMail(ctx, rec) && !(await isPresent(ctx, up.handle));
820
830
  const { status, reason, content, bytes, type, notifies, name, raw, receipt } = await handleDelivery(request, identFor(rec, policy),
821
831
  { podPut: held ? holdingPut(ctx, up.handle, rec) : (u, b, ct) => ctx.podPut(up.handle, u, b, ct),
822
- fetchImpl: ctx.fetchImpl, paused: standing.paused });
832
+ fetchImpl: ctx.fetchImpl, paused: standing.paused, origin: ctx.frontOrigin || null });
823
833
  // One line per delivery, so "did it arrive at the door" has an answer.
824
834
  console.log(`door @${up.handle}: delivery → ${status} (${reason}${held && status === 202 ? ', held' : ''})`);
825
835
  if (status === 202 && content) await noteReceived(ctx, up.handle, rec, bytes);
@@ -911,10 +921,9 @@ async function route(request, ctx) {
911
921
  const podTarget = rec.podHome + up.rest;
912
922
  // The owner's own documents — the full outbox, the liked list, the pending
913
923
  // lists — are read at the pod, which decides who may; never held here,
914
- // where one reader's answer would be served to the next.
915
- if (up.rest.startsWith('ap/private/')) {
916
- return { status: 303, headers: { ...open, location: podTarget, 'cache-control': 'no-store' }, body: '' };
917
- }
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 });
918
927
  // Media stays on the pod (lib/pod/urls.mjs keeps `media` off the front), but
919
928
  // the id rewrite below turns media links onto the front like every other
920
929
  // pod url in a document. Answer those by pointing at the pod: bytes are not
@@ -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.
@@ -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.
@@ -20,6 +20,7 @@ import { Agent } from '../core/agent.mjs';
20
20
  import { RemotePod } from '../core/remote.mjs';
21
21
  import { apUrls, DEFAULT_ROOT } from '../core/wire.mjs';
22
22
  import { handleDelivery } from '../gateway/gateway-core.mjs';
23
+ import { signedReader, mayRead } from '../gateway/private-read.mjs';
23
24
  import { writeJsonAtomic } from '../shared/files.mjs';
24
25
  import { buildAdminSurface } from '../surface/index.mjs';
25
26
  import { FixedAuthorities } from '../shared/guard.mjs';
@@ -260,6 +261,30 @@ export async function deliverToInbox(agent, request, { podPut, gatewayWebId = nu
260
261
  return handleDelivery(request, ident, { podPut, fetchImpl });
261
262
  }
262
263
 
264
+ /**
265
+ * A signed read of a followers-only or direct post at its address on this
266
+ * pod (ActivityPub §3.2), answered to a server the post was sent to. The
267
+ * rule is lib/gateway/private-read.mjs's; here the post is read through the
268
+ * server's own store and the followers come from the identity's live state.
269
+ * Returns { status, reason, body }: 200 with the document, else 404 with why.
270
+ */
271
+ export async function readPrivateForSigner(agent, request, { read, fetchImpl = fetch } = {}) {
272
+ const u = agent.urls;
273
+ const toPod = (x) => (u.toPod ? u.toPod(x) : x);
274
+ const url = request.url.split('?')[0];
275
+ const { actor, reason } = await signedReader(request, { fetchImpl, origin: new URL(request.url).origin });
276
+ if (!actor) return { status: 404, reason: `refused — ${reason}` };
277
+ const text = await Promise.resolve().then(() => read(url)).catch(() => null);
278
+ let doc = null;
279
+ try { doc = text ? JSON.parse(text) : null; } catch { doc = null; }
280
+ if (!doc) return { status: 404, reason: `refused — nothing at ${url} (${actor})` };
281
+ const followers = (agent.store.getContacts().followers || []).map((f) => f?.actor).filter(Boolean);
282
+ if (!mayRead(doc, actor, { followersUrls: [u.followers, toPod(u.followers)], followers })) {
283
+ return { status: 404, reason: `refused — ${actor} is not in its audience` };
284
+ }
285
+ return { status: 200, reason: `served to ${actor}`, body: text };
286
+ }
287
+
263
288
  /**
264
289
  * Bring one identity up inside the server.
265
290
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "1.44.0",
3
+ "version": "1.45.0",
4
4
  "description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -55,7 +55,7 @@
55
55
  "install-service": "node bin/fedipod.mjs install-service",
56
56
  "uninstall-service": "node bin/fedipod.mjs uninstall-service",
57
57
  "prepublishOnly": "npm test",
58
- "test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node packages/fedipod-bb/scripts/link-fedipod.mjs && node --test packages/fedipod-bb/test/*.test.mjs claude/smoke-tests/forum-signup-form.test.mjs && node scripts/check-sizes.mjs",
58
+ "test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node packages/fedipod-bb/scripts/link-fedipod.mjs && node --test packages/fedipod-bb/test/*.test.mjs claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs && node scripts/check-sizes.mjs && node claude/validation/browser-agent/all.mjs",
59
59
  "verify-ui": "node scripts/verify-ui.mjs",
60
60
  "build:sw": "node scripts/build-app.mjs --entry web/app/sw-src.mjs --out web/app/dist/sw.js",
61
61
  "build:boot": "node scripts/build-app.mjs --entry web/app/boot.mjs --out web/app/dist/boot.js",