fedipod-server 0.4.0 → 0.6.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
@@ -12,7 +12,7 @@ made, and opting out is the only way one ends.
12
12
  With nothing configured beyond the defaults, installing the component changes
13
13
  nothing about how the server serves pods.
14
14
 
15
- ![FediPod as a component of a Solid server](fedipod-server.svg)
15
+ ![A pod and its agent, both inside one Solid server](fedipod-server.svg)
16
16
 
17
17
  ## Install
18
18
 
@@ -189,6 +189,8 @@ npm run test:e2e # boots a real server and uses it as a client would
189
189
  container listings — through the transport an identity uses, and checks the
190
190
  lease protocol and the deletion deny-list still hold across it.
191
191
 
192
+ `npm test` is also what a publish runs, so a red suite blocks one.
193
+
192
194
  `npm run test:e2e` starts a real Community Solid Server with three pods (two
193
195
  become identities up front; the third opts in and out at runtime), then signs in as a phone app does, posts,
194
196
  receives a follow from another server, and watches the live feed. It takes
@@ -1,134 +1,82 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1620 1140" width="1620" height="1140" font-family="system-ui, sans-serif" role="img" aria-labelledby="ttl dsc">
2
- <title id="ttl">FediPod Server, a Community Solid Server component</title>
3
- <desc id="dsc">One Community Solid Server hosting many pods, with the FediPod component installed, and down the right-hand side everyone who talks to it. The open social web delivers mail and looks things up: some of that reaches the component, and the rest reaches pod storage directly, because a pod's public record and its inbox are ordinary pod resources the server already serves. Mei's phone and Tamara's laptop sign in, read and post; any authorized Solid app reads and writes the pods directly, except the paths the component claims. Inside, the component is the same FediPod Agent running in the server's own process, in two parts. The endpoints it serves are, on each pod's own address, the client API an app signs in to, the owner's pages behind that owner's own secret, and the live feed; and on the shared address, the front, and the door that verifies deliveries for the handles it fronts. The work it does for each pod is accepting a follow, delivering a post, emptying the inbox, and holding the lease so only one agent at a time acts. Below, server storage holds, outside every pod, each identity's signing key and door secret, plus a handle directory and the opt-in list when the server serves other people; an identity's log goes to the server's own log. Pod storage is one pod per identity, each holding that identity's public face, its inbox and its private state, written directly with no credential and no second connection, and a pod owner opts in from there.</desc>
4
- <style>
5
- .t { font-size: 18px; font-weight: 600; fill: #1a1a19; }
6
- .d { font-size: 16px; fill: #333; }
7
- .sub { font-size: 15px; fill: #666; }
8
- .lbl { font-size: 15px; fill: #555; }
9
- .cc { font-size: 11px; font-weight: 400; font-style: italic; fill: #555; }
10
- .arrow { stroke: #555; stroke-width: 2; fill: none; marker-end: url(#arr); }
11
- .arrow2 { stroke: #555; stroke-width: 2; fill: none; marker-end: url(#arr); marker-start: url(#arr); }
12
- </style>
13
- <defs>
14
- <marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
15
- <path d="M0,0 L10,5 L0,10 z" fill="#555"/>
16
- </marker>
17
- </defs>
18
-
19
- <rect width="1620" height="1140" fill="#ffffff"/>
20
-
21
- <!-- ===== the server ===== -->
22
- <rect x="40" y="40" width="1060" height="1050" rx="12" fill="#f7f6f2" stroke="#9a988f"/>
23
- <text class="t" x="570" y="80" text-anchor="middle">One Community Solid Server, hosting many pods, with the FediPod component installed</text>
24
- <text class="sub" x="570" y="104" text-anchor="middle">the pods it already served, and a fediverse identity for each user who opts in</text>
25
-
26
- <!-- ===== processing ===== -->
27
- <rect x="64" y="128" width="1012" height="518" rx="12" fill="#fdeeee" stroke="#b87a7a"/>
28
- <text class="t" x="570" y="164" text-anchor="middle">Processing — the same FediPod Agent, running inside the server</text>
29
-
30
- <text class="sub" x="88" y="196">the endpoints it serves, on each pod's own address</text>
31
- <rect x="88" y="206" width="468" height="68" rx="10" fill="#ffffff" stroke="#c9c7bf"/>
32
- <text class="d" x="106" y="234">The client API</text>
33
- <text class="sub" x="106" y="256">what a Mastodon app signs in to</text>
34
- <rect x="88" y="286" width="468" height="68" rx="10" fill="#ffffff" stroke="#c9c7bf"/>
35
- <text class="d" x="106" y="314">The owner's pages</text>
36
- <text class="sub" x="106" y="336">the record and a web client, behind their own secret</text>
37
- <rect x="88" y="366" width="468" height="68" rx="10" fill="#ffffff" stroke="#c9c7bf"/>
38
- <text class="d" x="106" y="394">The live feed</text>
39
- <text class="sub" x="106" y="416">a socket a client holds open</text>
40
-
41
- <text class="sub" x="88" y="464">and on the shared address</text>
42
- <rect x="88" y="474" width="468" height="68" rx="10" fill="#ffffff" stroke="#c9c7bf"/>
43
- <text class="d" x="106" y="502">The front</text>
44
- <text class="sub" x="106" y="524">signup, name lookups and attach</text>
45
- <rect x="88" y="554" width="468" height="68" rx="10" fill="#ffffff" stroke="#c9c7bf"/>
46
- <text class="d" x="106" y="582">The door</text>
47
- <text class="sub" x="106" y="604">verifies deliveries for the handles it fronts</text>
48
-
49
- <text class="sub" x="584" y="196">the work it does, for each pod it runs</text>
50
- <rect x="584" y="206" width="468" height="68" rx="10" fill="#ffffff" stroke="#c9c7bf"/>
51
- <text class="d" x="602" y="234">Accepts a follow</text>
52
- <text class="sub" x="602" y="256">answers as that pod's owner</text>
53
- <rect x="584" y="286" width="468" height="68" rx="10" fill="#ffffff" stroke="#c9c7bf"/>
54
- <text class="d" x="602" y="314">Delivers a post</text>
55
- <text class="sub" x="602" y="336">signs it and sends it on</text>
56
- <rect x="584" y="366" width="468" height="68" rx="10" fill="#ffffff" stroke="#c9c7bf"/>
57
- <text class="d" x="602" y="394">Empties the inbox</text>
58
- <text class="sub" x="602" y="416">reads what arrived, then clears it</text>
59
- <rect x="584" y="446" width="468" height="68" rx="10" fill="#ffffff" stroke="#c9c7bf"/>
60
- <text class="d" x="602" y="474">Holds the lease</text>
61
- <text class="sub" x="602" y="496">so only one agent at a time acts on a pod</text>
62
-
63
- <!-- ===== processing <-> storage ===== -->
64
- <path class="arrow" d="M 298 648 L 298 742"/>
65
- <text class="lbl" x="286" y="678" text-anchor="end">keeps each identity's</text>
66
- <text class="lbl" x="286" y="700" text-anchor="end">signing key and door secret</text>
67
-
68
- <path class="arrow" d="M 798 648 L 798 742"/>
69
- <text class="lbl" x="786" y="678" text-anchor="end">writes directly to each user's pod</text>
70
- <text class="lbl" x="786" y="700" text-anchor="end">no credential, no second connection</text>
71
-
72
- <path class="arrow" d="M 838 742 L 838 648"/>
73
- <text class="lbl" x="850" y="678">user opts in to have a</text>
74
- <text class="lbl" x="850" y="700">fediverse account on their pod</text>
75
-
76
- <!-- ===== server storage ===== -->
77
- <rect x="64" y="746" width="468" height="320" rx="12" fill="#f2ece0" stroke="#b09a5e"/>
78
- <text class="t" x="298" y="782" text-anchor="middle">Server storage</text>
79
- <text class="sub" x="298" y="806" text-anchor="middle">on the server, outside every pod</text>
80
- <text class="sub" x="298" y="826" text-anchor="middle">an identity's log goes to the server's own log</text>
81
- <rect x="88" y="852" width="420" height="68" rx="10" fill="#ffffff" stroke="#c9c7bf"/>
82
- <text class="d" x="106" y="880">A signing key per identity</text>
83
- <text class="sub" x="106" y="902">what it means to be able to post as them</text>
84
- <rect x="88" y="932" width="420" height="68" rx="10" fill="#ffffff" stroke="#c9c7bf"/>
85
- <text class="d" x="106" y="960">A door secret per identity</text>
86
- <text class="sub" x="106" y="982">opens that identity's pages, nobody else's</text>
87
- <rect x="88" y="1012" width="420" height="46" rx="10" fill="#ffffff" stroke="#c9c7bf" stroke-dasharray="4 4"/>
88
- <text class="sub" x="106" y="1040">a handle directory and the opt-in list, if it serves others</text>
89
-
90
- <!-- ===== pod storage ===== -->
91
- <rect x="584" y="746" width="468" height="320" rx="12" fill="#e7f2e7" stroke="#6a9a6a"/>
92
- <text class="t" x="818" y="782" text-anchor="middle">Pod storage — one pod per identity</text>
93
- <text class="sub" x="818" y="806" text-anchor="middle">addressable by Solid and by ActivityPub</text>
94
- <rect x="608" y="832" width="420" height="68" rx="10" fill="#ffffff" stroke="#c9c7bf"/>
95
- <text class="d" x="626" y="860">mei.example.org</text>
96
- <text class="sub" x="626" y="882">public face, inbox, and her agent's private state</text>
97
- <rect x="608" y="912" width="420" height="68" rx="10" fill="#ffffff" stroke="#c9c7bf"/>
98
- <text class="d" x="626" y="940">tamara.example.org</text>
99
- <text class="sub" x="626" y="962">the same again, hers alone</text>
100
- <rect x="608" y="992" width="420" height="46" rx="10" fill="#ffffff" stroke="#c9c7bf" stroke-dasharray="4 4"/>
101
- <text class="sub" x="626" y="1020">and every other pod this server hosts, untouched</text>
102
-
103
- <!-- ===== everyone who talks to it ===== -->
104
- <rect x="1160" y="170" width="340" height="104" rx="12" fill="#f0e9f5" stroke="#9a7ab0"/>
105
- <text class="t" x="1330" y="206" text-anchor="middle">Mei's phone</text>
106
- <text class="d" x="1330" y="234" text-anchor="middle">any mobile Mastodon client</text>
107
- <text class="d" x="1330" y="256" text-anchor="middle">signs in, reads, posts</text>
108
- <path class="arrow2" d="M 1158 222 L 1080 222"/>
109
-
110
- <rect x="1160" y="310" width="340" height="104" rx="12" fill="#f0e9f5" stroke="#9a7ab0"/>
111
- <text class="t" x="1330" y="346" text-anchor="middle">Tamara's laptop</text>
112
- <text class="d" x="1330" y="374" text-anchor="middle">browser or native app</text>
113
- <text class="d" x="1330" y="396" text-anchor="middle">signs in, reads, posts</text>
114
- <path class="arrow2" d="M 1158 362 L 1080 362"/>
115
-
116
- <rect x="1160" y="450" width="340" height="126" rx="12" fill="#eef1f8" stroke="#7a88b8"/>
117
- <text class="t" x="1330" y="486" text-anchor="middle">The open social web</text>
118
- <text class="d" x="1330" y="514" text-anchor="middle">Mastodon, Lemmy, PeerTube,</text>
119
- <text class="d" x="1330" y="536" text-anchor="middle">anyone else</text>
120
- <text class="d" x="1330" y="558" text-anchor="middle">delivers mail, and looks things up</text>
121
- <path class="arrow2" d="M 1158 513 L 1080 513"/>
122
- <path class="arrow2" d="M 1330 578 L 1330 906 L 1058 906"/>
123
- <text class="lbl" x="1348" y="734">to the pod — mail into its inbox</text>
124
- <text class="lbl" x="1348" y="756">from the pod — its public record</text>
125
-
126
- <rect x="1160" y="940" width="340" height="126" rx="12" fill="#f1efe8" stroke="#9a988f"/>
127
- <text class="t" x="1330" y="976" text-anchor="middle">Any authorized Solid app</text>
128
- <text class="d" x="1330" y="1004" text-anchor="middle">reads and writes the pod,</text>
129
- <text class="d" x="1330" y="1026" text-anchor="middle">except the paths the</text>
130
- <text class="d" x="1330" y="1048" text-anchor="middle">component claims</text>
131
- <path class="arrow2" d="M 1158 1003 L 1056 1003"/>
132
-
133
- <text class="cc" x="1580" y="1120" text-anchor="end">(cc) 4.0 By, Jeff Zucker, 2026</text>
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1290 800" width="1290" height="800" font-family="system-ui, sans-serif">
2
+ <defs>
3
+ <marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
4
+ <path d="M0,0 L10,5 L0,10 z" fill="#555"/>
5
+ </marker>
6
+ </defs>
7
+ <style>
8
+ .t { font-size: 18px; font-weight: 600; fill: #1a1a19; }
9
+ .d { font-size: 16px; fill: #333; }
10
+ .sub { font-size: 15px; fill: #666; }
11
+ .lbl { font-size: 15px; fill: #555; }
12
+ .cc { font-size: 11px; font-weight: 400; font-style: italic; fill: #555; }
13
+ .arrow { stroke: #555; stroke-width: 2; fill: none; marker-end: url(#arr); }
14
+ .arrow2 { stroke: #555; stroke-width: 2; fill: none; marker-end: url(#arr); marker-start: url(#arr); }
15
+ </style>
16
+
17
+ <rect width="1290" height="800" fill="#ffffff"/>
18
+
19
+ <!-- The open social web -->
20
+ <rect x="210" y="30" width="870" height="92" rx="12" fill="#eef1f8" stroke="#7a88b8"/>
21
+ <text class="t" x="645" y="64" text-anchor="middle">The open social web</text>
22
+ <text class="d" x="645" y="94" text-anchor="middle">other servers: deliver mail to us, read what we publish</text>
23
+
24
+ <!-- The server holding both -->
25
+ <rect x="180" y="200" width="930" height="301" rx="12" fill="#f7f6f2" stroke="#9a988f"/>
26
+ <text class="t" x="645" y="234" text-anchor="middle">FediPod Server (CSS + FediPod component)</text>
27
+ <text class="sub" x="645" y="258" text-anchor="middle">the pods it already served, and a fediverse identity for each user who opts in</text>
28
+
29
+ <!-- Pod -->
30
+ <rect x="210" y="280" width="360" height="197" rx="12" fill="#e7f2e7" stroke="#6a9a6a"/>
31
+ <text class="t" x="390" y="312" text-anchor="middle">Tamara's pod</text>
32
+ <text class="sub" x="390" y="336" text-anchor="middle">a subdomain pod on this server</text>
33
+ <text class="d" x="390" y="364" text-anchor="middle">stores &amp; serves her public</text>
34
+ <text class="d" x="390" y="386" text-anchor="middle">record in S2S AS2 format —</text>
35
+ <text class="d" x="390" y="408" text-anchor="middle">actor, posts, collections;</text>
36
+ <text class="d" x="390" y="430" text-anchor="middle">her inbox buffers mail</text>
37
+ <text class="d" x="390" y="452" text-anchor="middle">until her agent sweeps it</text>
38
+
39
+ <!-- Agent -->
40
+ <rect x="720" y="280" width="360" height="197" rx="12" fill="#fdeeee" stroke="#b87a7a"/>
41
+ <text class="t" x="900" y="312" text-anchor="middle">Tamara's FediPod Agent</text>
42
+ <text class="sub" x="900" y="336" text-anchor="middle">inside the server, no machine of her own</text>
43
+ <text class="d" x="900" y="364" text-anchor="middle">does the thinking:</text>
44
+ <text class="d" x="900" y="386" text-anchor="middle">reads the buffered mail,</text>
45
+ <text class="d" x="900" y="408" text-anchor="middle">accepts follows, builds her</text>
46
+ <text class="d" x="900" y="430" text-anchor="middle">timeline, signs &amp; delivers</text>
47
+ <text class="d" x="900" y="452" text-anchor="middle">everything she says</text>
48
+
49
+ <!-- Client -->
50
+ <rect x="720" y="560" width="360" height="136" rx="12" fill="#f0e9f5" stroke="#9a7ab0"/>
51
+ <text class="t" x="900" y="594" text-anchor="middle">Browser or Native App</text>
52
+ <text class="sub" x="900" y="618" text-anchor="middle">(Phanpy, Whalebird, …)</text>
53
+ <text class="d" x="900" y="646" text-anchor="middle">where she reads and posts;</text>
54
+ <text class="d" x="900" y="668" text-anchor="middle">signs in at her pod's address</text>
55
+
56
+ <!-- open social web -> pod: straight in, there is no door in the way -->
57
+ <path class="arrow" d="M 390 124 L 390 276"/>
58
+ <text class="lbl" x="402" y="160">deliveries in</text>
59
+
60
+ <!-- pod <-> agent -->
61
+ <path class="arrow2" d="M 572 378 L 718 378"/>
62
+ <text class="lbl" x="645" y="360" text-anchor="middle">drains mail,</text>
63
+ <text class="lbl" x="645" y="402" text-anchor="middle">writes the record</text>
64
+
65
+ <!-- agent <-> client -->
66
+ <path class="arrow2" d="M 900 479 L 900 558"/>
67
+ <text class="lbl" x="912" y="524">read / post</text>
68
+
69
+ <!-- pod -> open social web -->
70
+ <path class="arrow" d="M 208 345 L 70 345 C 45 345, 38 310, 38 260 C 38 190, 88 100, 206 100"/>
71
+ <text class="lbl" x="188" y="317" text-anchor="end">reads her record</text>
72
+
73
+ <!-- agent -> open social web -->
74
+ <path class="arrow" d="M 1082 345 L 1220 345 C 1245 345, 1252 310, 1252 250 C 1252 180, 1202 78, 1084 78"/>
75
+ <text class="lbl" x="1102" y="317">signed deliveries out</text>
76
+
77
+ <text class="d" x="45" y="646">A pod becomes an identity when its owner opts in, and stops when they opt out.</text>
78
+ <text class="d" x="45" y="670">The agent runs inside the server, so there is no second machine and nothing</text>
79
+ <text class="d" x="45" y="694">to authenticate between them — and the server holds her signing key.</text>
80
+
81
+ <text class="cc" x="1245" y="764" text-anchor="end">(cc) 4.0 By, Jeff Zucker, 2026</text>
134
82
  </svg>
package/lib/admin.mjs CHANGED
@@ -32,6 +32,7 @@ import { portFree, freePortFrom } from './ports.mjs';
32
32
  import { claimDirectory, yieldDirectory } from './directory.mjs';
33
33
  import { localFetch } from './localapi.mjs';
34
34
  import { ensureTrustedTls } from './certs.mjs';
35
+ import { localVersion } from './update.mjs';
35
36
 
36
37
  const require = createRequire(import.meta.url);
37
38
  const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -325,7 +326,8 @@ function sendFile(res, baseDir, rel, auth) {
325
326
  // guards the operator's door (basePath) instead of the whole surface.
326
327
  export function buildAdminSurface({ agent, gate, allowed, log = console.log,
327
328
  port = null, handle = null, embedded = false, basePath = '/',
328
- publicOrigin = null, scheme = null }) {
329
+ publicOrigin = null, scheme = null,
330
+ versionOnDisk = () => localVersion(projectRoot) }) {
329
331
  const json = (res, status, obj) => sendJson(res, status, obj, allowed);
330
332
  const masto = new MastoApi({ agent, log, allowed, scheme, embedded });
331
333
  // The spec's own write API (§6), beside the facade. Its bearer fallback is
@@ -592,6 +594,10 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
592
594
  privateRoot: agent.readCredential?.()?.privateRoot || null,
593
595
  mode: agent.status?.().mode || null, port, home: tildify(agent.home) || null,
594
596
  update: agent.updateInfo || null,
597
+ // What this process is running, against what a restart would run:
598
+ // the checkout can move while an agent stays up.
599
+ version: AGENT_VERSION,
600
+ versionOnDisk: versionOnDisk(),
595
601
  pendingUpgrade: agent.pendingUpgrade || [],
596
602
  // The connected Bluesky account, non-secret half. `connected` is the
597
603
  // credential's word, so a config entry orphaned by a deleted
@@ -1423,6 +1429,16 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1423
1429
  const cfg = { ...agent.store.getConfig() };
1424
1430
  const g = { ...(cfg.gateway || {}) };
1425
1431
  const inboxUrl = agent.publisher?.urls?.inbox;
1432
+ // Availability is a read: answered before the lease takeover below,
1433
+ // which every real gateway change does want.
1434
+ if (body.action === 'check') {
1435
+ const front = String(body.front || '').replace(/\/+$/, '');
1436
+ try { new URL(front); } catch { return json(res, 400, { error: 'front must be a gateway origin URL' }); }
1437
+ const chk = await fetch(`${front}/api/handle?handle=${encodeURIComponent(String(body.handle || '').toLowerCase())}`,
1438
+ { headers: { accept: 'application/json' } }).then((r) => r.json()).catch(() => null);
1439
+ if (!chk) return json(res, 502, { error: `${front} did not answer its handle check` });
1440
+ return json(res, 200, { available: !!chk.available, reason: chk.reason || null });
1441
+ }
1426
1442
  await agent.requestTakeover?.();
1427
1443
  const persist = async () => {
1428
1444
  cfg.gateway = g; agent.store.setConfig(cfg);
@@ -1478,6 +1494,56 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1478
1494
  else if (target !== 'off') await agent.publisher?.publishGatewayPolicy().catch(() => {});
1479
1495
  return json(res, 200, { ok: true, mode: g.mode });
1480
1496
  }
1497
+ // Attach through a multi-user front (fedipod.net and kin): the agent
1498
+ // proves the pod with its own credential — no browser, no password —
1499
+ // and the front answers with the door and the receipt secret.
1500
+ if (body.action === 'attach') {
1501
+ const front = String(body.front || '').replace(/\/+$/, '');
1502
+ let fu;
1503
+ try { fu = new URL(front); } catch { return json(res, 400, { error: 'front must be the gateway origin, like https://fedipod.net' }); }
1504
+ if (fu.protocol !== 'https:' && !/^(localhost|127\.0\.0\.1)$|\.localhost$/.test(fu.hostname)) {
1505
+ return json(res, 400, { error: 'front must be https' });
1506
+ }
1507
+ if (agent.embedded) {
1508
+ return json(res, 400, { error: 'this identity runs inside its pod server and has no portable credential — attach from a standalone agent' });
1509
+ }
1510
+ const handle = String(body.handle || cfg.handle || '').toLowerCase().trim();
1511
+ if (!handle) return json(res, 400, { error: 'a name at the gateway is required' });
1512
+ const fronted = body.fronted === true;
1513
+ const frontActor = `${front}/u/${handle}/ap/actor`;
1514
+ if (g.frontActor && (!fronted || g.frontActor !== frontActor)) {
1515
+ return json(res, 400, { error: `this identity already fronts through ${g.frontActor} — changing a published front renames every id; detach first if you mean it` });
1516
+ }
1517
+ // Availability first, for a clean answer before anything is created.
1518
+ const chk = await fetch(`${front}/api/handle?handle=${encodeURIComponent(handle)}`,
1519
+ { headers: { accept: 'application/json' } }).then((r) => r.json()).catch(() => null);
1520
+ if (!chk) return json(res, 502, { error: `${front} did not answer its handle check` });
1521
+ if (!chk.available) return json(res, 409, { error: chk.reason || `the name ${handle} is taken at ${front}` });
1522
+ const attach = await agent.remote.session.fetch(`${front}/api/attach`, {
1523
+ method: 'POST', headers: { 'content-type': 'application/json' },
1524
+ body: JSON.stringify({ handle, podHome: agent.urls.home, kind: cfg.kind || 'person', fronted }),
1525
+ }).catch(() => null);
1526
+ if (!attach) return json(res, 502, { error: `${front} did not answer the attach` });
1527
+ const d = await attach.json().catch(() => ({}));
1528
+ if (attach.status !== 201) {
1529
+ return json(res, attach.status >= 400 && attach.status < 500 ? attach.status : 502,
1530
+ { error: d.error || `attach failed (HTTP ${attach.status})` });
1531
+ }
1532
+ g.url = String(d.doorInbox || `${front}/u/${handle}/ap/inbox/`);
1533
+ if (d.hmacSecret) g.hmacSecret = String(d.hmacSecret);
1534
+ if (fronted) g.frontActor = String(d.frontActor || frontActor);
1535
+ if (!g.mode || g.mode === 'off') g.mode = 'shadow';
1536
+ await persist();
1537
+ // Inbox-only applies live: the actor republishes advertising the
1538
+ // door. A front carries new ids, which are wired at startup — that
1539
+ // attach persists and owes a restart.
1540
+ if (!fronted) {
1541
+ await agent.publisher?.publishProfile();
1542
+ await agent.publisher?.publishGatewayPolicy?.().catch(() => {});
1543
+ }
1544
+ return json(res, 200, { ok: true, mode: g.mode, url: g.url,
1545
+ ...(fronted ? { frontActor: g.frontActor, address: d.address || null, restart: true } : {}) });
1546
+ }
1481
1547
  if (body.action === 'forget') {
1482
1548
  const wasLocked = g.mode === 'locked';
1483
1549
  delete cfg.gateway; agent.store.setConfig(cfg);
@@ -1487,7 +1553,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1487
1553
  await agent.publisher?.publishProfile(); // re-advertise the pod inbox
1488
1554
  return json(res, 200, { ok: true, mode: 'off', forgotten: true });
1489
1555
  }
1490
- return json(res, 400, { error: 'action must be configure, mode or forget' });
1556
+ return json(res, 400, { error: 'action must be configure, mode, check, attach or forget' });
1491
1557
  }
1492
1558
  // Symmetrical with /block, and open for the same reason: a block made
1493
1559
  // by mistake is worth undoing before federation is even configured.
@@ -1534,14 +1600,17 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1534
1600
  return { handler, masto, c2s, streaming };
1535
1601
  }
1536
1602
 
1537
- export function startAdmin({ port, gateToken, agent, log = console.log, handle = null, tls = null }) {
1603
+ export function startAdmin({ port, gateToken, agent, log = console.log, handle = null, tls = null,
1604
+ // Injectable, so a test can put a checkout ahead of the running process
1605
+ // without editing the package.json of the machine running the test.
1606
+ versionOnDisk = () => localVersion(projectRoot) }) {
1538
1607
  const gate = makeGate(gateToken);
1539
1608
  // Live, so the named origin appears the moment connect() reads the handle
1540
1609
  // out of pod state — including for the OAuth redirect check in MastoApi.
1541
1610
  // The https listener's port joins the authority set: same names, second port.
1542
1611
  const allowed = new Authorities(port, handle);
1543
1612
  agent.authorities = allowed;
1544
- const { handler, streaming } = buildAdminSurface({ agent, gate, allowed, log, port, handle });
1613
+ const { handler, streaming } = buildAdminSurface({ agent, gate, allowed, log, port, handle, versionOnDisk });
1545
1614
 
1546
1615
  // Loopback both ways: the canonical URL is https://localhost:<port>/, and
1547
1616
  // "localhost" resolves to ::1 on many systems before falling back to IPv4 —
package/lib/deliver.mjs CHANGED
@@ -4,11 +4,43 @@
4
4
  // dropped after MAX_ATTEMPTS (~3 days).
5
5
 
6
6
  import { signRequest } from '@fedify/fedify/sig';
7
+ import { webcrypto } from 'node:crypto';
8
+ import { attachProof } from './proof.mjs';
7
9
  import { pinnedFor, retryAfterMs, HTTP_TIMEOUT_MS } from './safefetch.mjs';
8
10
  import { USER_AGENT } from './ua.mjs';
9
11
 
10
12
  const MAX_REDIRECTS = 3;
11
13
 
14
+ // draft-cavage signs `(request-target): <method> <path><query>`. Fedify 2.3.4
15
+ // builds that line from the pathname alone, so a signature over any URL
16
+ // carrying a query is one a correct verifier cannot reconstruct — a paged
17
+ // collection at `?page=2`, a WordPress inbox at `?rest_route=…`. Re-sign just
18
+ // that line, over the header list Fedify already chose; a URL with no query
19
+ // comes back untouched, and anything unexpected is left exactly as signed.
20
+ export async function withQueryInTarget(signed, privateKey) {
21
+ const url = new URL(signed.url);
22
+ if (!url.search) return signed;
23
+ const header = signed.headers.get('signature');
24
+ if (!header) return signed;
25
+ const fields = Object.fromEntries(
26
+ [...header.matchAll(/([A-Za-z]+)="([^"]*)"/g)].map(m => [m[1], m[2]]));
27
+ const names = fields.headers?.split(/\s+/g) || [];
28
+ if (!fields.signature || !names.includes('(request-target)')) return signed;
29
+ if (names.some(n => n.startsWith('(') && n !== '(request-target)')) return signed;
30
+ const message = names.map(n => n === '(request-target)'
31
+ ? `(request-target): ${signed.method.toLowerCase()} ${url.pathname}${url.search}`
32
+ : `${n}: ${(n === 'host' ? signed.headers.get('host') || url.host : signed.headers.get(n)) ?? ''}`)
33
+ .join('\n');
34
+ const sig = await webcrypto.subtle.sign('RSASSA-PKCS1-v1_5', privateKey,
35
+ new TextEncoder().encode(message));
36
+ const headers = new Headers(signed.headers);
37
+ headers.set('signature',
38
+ header.replace(/signature="[^"]*"/, `signature="${Buffer.from(sig).toString('base64')}"`));
39
+ const init = { method: signed.method, headers, signal: signed.signal };
40
+ if (signed.method !== 'GET' && signed.method !== 'HEAD') init.body = await signed.arrayBuffer();
41
+ return new Request(signed.url, init);
42
+ }
43
+
12
44
  const MAX_ATTEMPTS = 12; // 2^12 min ≈ 68h of backoff
13
45
  const TICK_MS = 60_000;
14
46
  const MAX_QUEUE = 2000; // beyond it, overflow dead-letters
@@ -25,10 +57,14 @@ export class Deliverer {
25
57
  // passive: signing-only (viewer-mode agents) — no queue drain timer, so a
26
58
  // read-only agent never mutates shared delivery state. startQueue() flips
27
59
  // it live when a viewer is promoted to active.
28
- constructor({ store, keyId, rsaPrivate, log = console.log, passive = false }) {
60
+ constructor({ store, keyId, rsaPrivate, actorId = null, edPrivate = null, proofKeyId = null,
61
+ log = console.log, passive = false }) {
29
62
  this.store = store;
30
63
  this.keyId = keyId;
31
64
  this.rsaPrivate = rsaPrivate;
65
+ this.actorId = actorId;
66
+ this.edPrivate = edPrivate;
67
+ this.proofKeyId = proofKeyId;
32
68
  this.log = log;
33
69
  if (!passive) this.startQueue();
34
70
  }
@@ -64,7 +100,8 @@ export class Deliverer {
64
100
  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
65
101
  const dispatcher = await pinnedFor(current);
66
102
  const req = new Request(current, withUa);
67
- const signed = await signRequest(req, this.rsaPrivate, new URL(this.keyId));
103
+ const signed = await withQueryInTarget(
104
+ await signRequest(req, this.rsaPrivate, new URL(this.keyId)), this.rsaPrivate);
68
105
  const res = await fetch(signed, { ...(dispatcher ? { dispatcher } : {}), redirect: 'manual' });
69
106
  if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
70
107
  current = new URL(res.headers.get('location'), current).href;
@@ -101,20 +138,43 @@ export class Deliverer {
101
138
  }
102
139
 
103
140
  // Deliver, queueing on failure.
141
+ // FEP-8b32: a proof on everything WE author, so a server that meets one of
142
+ // our activities second-hand can verify it without asking us.
143
+ //
144
+ // Only ours. An activity we forward on someone else's behalf keeps whatever
145
+ // proof its author gave it — signing that one as an assertion of ours would
146
+ // be claiming to have written it.
147
+ async proofed(activity) {
148
+ if (!this.edPrivate || !this.proofKeyId || !activity || typeof activity !== 'object') return activity;
149
+ if (activity.proof) return activity;
150
+ const actor = typeof activity.actor === 'string' ? activity.actor : activity.actor?.id;
151
+ if (!this.actorId || actor !== this.actorId) return activity;
152
+ try {
153
+ return await attachProof(activity,
154
+ { privateKey: this.edPrivate, verificationMethod: this.proofKeyId });
155
+ } catch (e) {
156
+ this.log(`proof: ${e.message}`); // an unproved activity still federates
157
+ return activity;
158
+ }
159
+ }
160
+
104
161
  async deliver(inbox, activity) {
162
+ // Proved before anything else, so the copy that goes on the queue is the
163
+ // copy that was signed — a retry days later must not post a bare activity.
164
+ const signed = await this.proofed(activity);
105
165
  // A host we already know is refusing: queue without asking again. This is
106
166
  // the path a FRESH activity takes, so without it a fan-out to a struggling
107
167
  // server opened one socket per follower before any of this applied.
108
168
  const host = hostOf(inbox);
109
169
  const until = this._cooling?.get(host);
110
170
  if (until && until > Date.now()) {
111
- this.log(`${host} is cooling — queueing ${activity.type} rather than asking again`);
112
- this._enqueue({ inbox, activity, attempts: 1, nextAt: until });
171
+ this.log(`${host} is cooling — queueing ${signed.type} rather than asking again`);
172
+ this._enqueue({ inbox, activity: signed, attempts: 1, nextAt: until });
113
173
  return;
114
174
  }
115
175
  try {
116
- await this.deliverNow(inbox, activity);
117
- this.log(`delivered ${activity.type} → ${inbox}`);
176
+ await this.deliverNow(inbox, signed);
177
+ this.log(`delivered ${signed.type} → ${inbox}`);
118
178
  } catch (e) {
119
179
  this.log(`delivery failed (${e.message}) — queued`);
120
180
  const wait = e.retryAfterMs || 60_000;
@@ -122,7 +182,7 @@ export class Deliverer {
122
182
  this._cooling ||= new Map();
123
183
  this._cooling.set(host, Date.now() + wait);
124
184
  }
125
- this._enqueue({ inbox, activity, attempts: 1, nextAt: Date.now() + wait });
185
+ this._enqueue({ inbox, activity: signed, attempts: 1, nextAt: Date.now() + wait });
126
186
  }
127
187
  }
128
188
 
@@ -140,8 +200,11 @@ export class Deliverer {
140
200
  }
141
201
 
142
202
  async deliverToAll(inboxes, activity) {
203
+ // Proved once for the whole fan-out: every recipient gets the same bytes,
204
+ // and one signature is computed rather than one per follower.
205
+ const signed = await this.proofed(activity);
143
206
  // Shared inboxes deduplicate fan-out to the same server.
144
- for (const inbox of [...new Set(inboxes)]) await this.deliver(inbox, activity);
207
+ for (const inbox of [...new Set(inboxes)]) await this.deliver(inbox, signed);
145
208
  }
146
209
 
147
210
  // Serialized, for the same reason Intake.drain is: the tick is 60s and a
@@ -33,7 +33,9 @@ function httpUrl(u) {
33
33
  function isBlocked(actor, blocklist = {}) {
34
34
  if (!actor) return true;
35
35
  if ((blocklist.actors || []).includes(actor)) return true;
36
- let host; try { host = new URL(actor).host; } catch { return false; }
36
+ // hostname, not host: the agent's own isBlocked strips the port, and a door
37
+ // that keeps it lets a blocked domain back in on a non-default port.
38
+ let host; try { host = new URL(actor).hostname; } catch { return false; }
37
39
  return (blocklist.domains || []).some(d => host === d || host.endsWith('.' + d));
38
40
  }
39
41
 
@@ -88,7 +90,7 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
88
90
  // reaches the pod (today it would, drain, and die unapplied). An absent or
89
91
  // unfetchable-key signature is NOT dropped: it forwards unverified and the
90
92
  // drain's verify-by-dereference still stands behind it.
91
- if (v.verified === false && v.reason === 'bad-signature-or-key-unfetchable') {
93
+ if (v.verified === false && v.reason === 'bad-signature') {
92
94
  return { status: 202, reason: 'forged signature' };
93
95
  }
94
96
 
package/lib/httpsig.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  // — so the key deref is injected as a loader that goes through safefetch.
11
11
 
12
12
  import crypto from 'node:crypto';
13
- import { verifyRequest } from '@fedify/fedify/sig';
13
+ import { verifyRequestDetailed } from '@fedify/fedify/sig';
14
14
  import { assertPublicUrl, safeFetch, readCapped } from './safefetch.mjs';
15
15
 
16
16
  // An SSRF-safe JSON-LD document loader for Fedify's key fetch. The keyId in a
@@ -40,33 +40,48 @@ export function makeSafeLoader({ getActors = null, fetchImpl = fetch } = {}) {
40
40
  // has one natively; an agent-side endpoint builds one from its req). Returns a
41
41
  // normalized result both the gateway and the drain understand — never throws
42
42
  // on a bad signature, because "unverified" is a routine outcome, not an error.
43
+ //
44
+ // Three outcomes, kept apart because the door acts on them differently. Only a
45
+ // signature we could CHECK and found wrong is a forgery. A key we could not
46
+ // fetch proves nothing: a sender in secure mode — Threads, Mastodon with
47
+ // AUTHORIZED_FETCH — answers this keyless loader 404 or 401, so its deliveries
48
+ // have to degrade to unverified rather than be read as forged.
43
49
  export async function verifyHttpSignature(request, { documentLoader, keyCache, timeWindow } = {}) {
44
50
  const hadSig = request.headers.get('signature') != null;
45
- let key = null;
51
+ let result = null;
46
52
  try {
47
- key = await verifyRequest(request, {
53
+ result = await verifyRequestDetailed(request, {
48
54
  documentLoader,
49
55
  ...(keyCache ? { keyCache } : {}),
50
56
  timeWindow: timeWindow ?? { hours: 1 },
51
57
  });
52
58
  } catch {
53
- key = null; // a loader that refused (SSRF, 401 from a secure-mode pod) unverified
59
+ result = null; // the library itself failed no more informative than a bad signature
54
60
  }
55
- if (key) {
61
+ if (result?.verified) {
62
+ const key = result.key;
56
63
  return {
57
64
  verified: true, method: 'draft-cavage',
58
65
  keyId: key.id?.href ?? null, actor: key.ownerId?.href ?? null, reason: null,
59
66
  checks: { signature: true, digest: true, dateSkew: true, keyFetched: true },
60
67
  };
61
68
  }
69
+ const kind = result?.reason?.type;
70
+ if (!hadSig || kind === 'noSignature') {
71
+ return {
72
+ verified: false, method: 'none', keyId: null, actor: null,
73
+ reason: 'no-signature', checks: { signature: false },
74
+ };
75
+ }
76
+ if (kind === 'keyFetchError') {
77
+ return {
78
+ verified: false, method: 'draft-cavage', keyId: null, actor: null,
79
+ reason: 'key-unfetchable', checks: { signature: false, keyFetched: false },
80
+ };
81
+ }
62
82
  return {
63
- verified: false, method: hadSig ? 'draft-cavage' : 'none',
64
- keyId: null, actor: null,
65
- // The distinction the receipt needs: a forgery (present but invalid) is
66
- // dropped at the edge; an absent or unfetchable-key signature degrades to
67
- // buffered-unverified, exactly as an install with no gateway behaves.
68
- reason: hadSig ? 'bad-signature-or-key-unfetchable' : 'no-signature',
69
- checks: { signature: false },
83
+ verified: false, method: 'draft-cavage', keyId: null, actor: null,
84
+ reason: 'bad-signature', checks: { signature: false },
70
85
  };
71
86
  }
72
87