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 +7 -3
- package/lib/client/c2s.mjs +21 -73
- package/lib/core/intake/index.mjs +8 -1
- package/lib/core/intake/received.mjs +78 -0
- package/lib/gateway/front-core.mjs +14 -5
- package/lib/gateway/gateway-core.mjs +46 -14
- package/lib/gateway/httpsig.mjs +6 -1
- package/lib/gateway/private-read.mjs +91 -0
- package/lib/pod/urls.mjs +3 -0
- package/lib/server/embed.mjs +25 -0
- package/package.json +2 -2
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
|
|
77
|
-
|
|
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:
|
package/lib/client/c2s.mjs
CHANGED
|
@@ -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
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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 = `${
|
|
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
|
-
|
|
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
|
-
|
|
110
|
+
doc = await this.agent.remote.getJson(page ? `${own}-${page}` : own);
|
|
125
111
|
} catch (e) {
|
|
126
|
-
return this.send(res, 502, { error: `the
|
|
112
|
+
return this.send(res, 502, { error: `the inbox collection could not be read: ${e.message}` });
|
|
127
113
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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)
|
|
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
|
-
|
|
916
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
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
|
-
|
|
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' :
|
|
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.
|
package/lib/gateway/httpsig.mjs
CHANGED
|
@@ -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.
|
package/lib/server/embed.mjs
CHANGED
|
@@ -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.
|
|
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",
|