fedipod 0.14.1 → 0.17.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/lib/admin.mjs CHANGED
@@ -27,7 +27,7 @@ import { identityHomes, rootOf, tildify, defaultProfile, writeJsonAtomic } from
27
27
  import { copyPrivateHalf, isCurrent, CURRENT_LAYOUT } from './migrate.mjs';
28
28
  import { normalizeImport, IMPORT_KINDS } from './import.mjs';
29
29
  import { insecureUrlReason } from './safefetch.mjs';
30
- import { newRun, preflight, runSetup, setupInputError, hasCredential } from './setup.mjs';
30
+ import { newRun, preflight, runSetup, setupInputError, hasCredential, credentialPath } from './setup.mjs';
31
31
  import { portFree, freePortFrom } from './ports.mjs';
32
32
  import { claimDirectory, yieldDirectory } from './directory.mjs';
33
33
  import { localFetch } from './localapi.mjs';
@@ -112,7 +112,7 @@ const AGENT_VERSION = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package
112
112
  // /shutdown is here because stopping an agent that was never set up is exactly
113
113
  // the case it exists for; it is in LOCAL_ONLY_POSTS below, so it still answers
114
114
  // only to this machine.
115
- const OPEN_POSTS = new Set(['/block', '/unblock', '/setup', '/setup/check', '/shutdown']);
115
+ const OPEN_POSTS = new Set(['/block', '/unblock', '/setup', '/setup/check', '/setup/reset', '/shutdown']);
116
116
 
117
117
  // The page the other server's redirect lands on. Self-contained on purpose:
118
118
  // the browser arrives here from somewhere else, and nothing may load from
@@ -147,11 +147,11 @@ function callbackPage(ok, msg) {
147
147
  // server there is no such process and no such machine: identities come from
148
148
  // the server's own configuration, so these are not there to be found.
149
149
  const EMBEDDED_CUT = new Set(['/profiles', '/shutdown', '/new-actor', '/start-actor',
150
- '/state-move', '/setup', '/setup/check']);
150
+ '/state-move', '/setup', '/setup/check', '/setup/reset']);
151
151
  // AP_ALLOWED_HOSTS may name a tailnet host or a reverse-proxy domain. The
152
152
  // fediverse is welcome there; creating accounts and editing the record is for
153
153
  // whoever is sitting at the machine.
154
- const LOCAL_ONLY_POSTS = new Set(['/setup', '/setup/check', '/config', '/new-actor', '/start-actor', '/shutdown', '/state-move', '/atproto/connect', '/fediacct/connect', '/fediacct/disconnect', '/fediacct', '/gateway', '/alias', '/import', '/update']);
154
+ const LOCAL_ONLY_POSTS = new Set(['/setup', '/setup/check', '/setup/reset', '/config', '/new-actor', '/start-actor', '/shutdown', '/state-move', '/atproto/connect', '/fediacct/connect', '/fediacct/disconnect', '/fediacct', '/gateway', '/alias', '/import', '/update']);
155
155
  // The identity itself. Changing any of these means a different actor at a
156
156
  // different address, which is a new setup, not an edit.
157
157
  const PERMANENT_CONFIG = ['handle', 'remotePod', 'issuer', 'root', 'kind'];
@@ -485,9 +485,19 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
485
485
  // C2S (ActivityPub §6) carries its own authentication — a Solid-OIDC
486
486
  // DPoP proof or the facade's bearer — so the dk-token gate does not
487
487
  // stand in front of it. The Host/Origin firewall above still does.
488
- if (p === '/ap/outbox' || p === '/ap/actor') {
488
+ if (p === '/ap/outbox' || p === '/ap/actor' || p === '/ap/inbox') {
489
489
  if (await c2s.handle(req, res, p, url)) return;
490
490
  }
491
+ // Where a client looks first to find out how to sign in (RFC 8414), and
492
+ // in front of the door for the same reason C2S is: a client that has to
493
+ // be handed a secret before it can ask how to sign in cannot set itself
494
+ // up at all. It names endpoints and nothing else, the endpoints it names
495
+ // refuse without a password anyway, and the host and origin firewall
496
+ // above still decides who gets this far.
497
+ if (p === '/.well-known/oauth-authorization-server') {
498
+ const scheme = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
499
+ return json(res, 200, masto.authorizationServerMetadata(`${scheme}://${req.headers.host}`));
500
+ }
491
501
  if (atDoor && gate(req, res)) return;
492
502
  if (p === '/api/v1/streaming/health') {
493
503
  res.writeHead(200, { 'content-type': 'text/plain' }); res.end('OK'); return;
@@ -848,6 +858,34 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
848
858
  }
849
859
  // ---- setup, driven by the page at /admin/setup/ ----
850
860
  case '/setup/check': return json(res, 200, preflight(body));
861
+ // Discard a credential that never finished setup, so the account and
862
+ // pod can be entered again. The credential a CSS server mints is shown
863
+ // once, so a setup that stops after the mint (a wrong pod answers 401
864
+ // to the first write) leaves the form in "finish" mode with no way to
865
+ // re-enter what was wrong. This removes it locally and reopens the full
866
+ // form. It does NOT revoke server-side — that needs the account
867
+ // password (`fedipod revoke-credential`); the old credential is left on
868
+ // the account, revocable from its dashboard.
869
+ case '/setup/reset': {
870
+ if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
871
+ // A working identity is never swapped out this way — that is a
872
+ // teardown (`fedipod retire`), not a half-finished setup.
873
+ if (agent.configured()) {
874
+ return json(res, 409, { error: 'this home holds a working identity — retire it, do not reset' });
875
+ }
876
+ if (setupRun?.phase === 'running') {
877
+ return json(res, 409, { error: 'setup is running — let it finish or stop it first', phase: 'running' });
878
+ }
879
+ const home = agent.home;
880
+ if (!home) return json(res, 500, { error: 'this agent has no AP_HOME to reset' });
881
+ const removed = hasCredential(home);
882
+ if (removed) fs.rmSync(credentialPath(home), { force: true });
883
+ // Drop the pod handle too, so configured() cannot flicker true off a
884
+ // stale in-memory session while the fresh form is filled in.
885
+ agent.remote = null;
886
+ setupRun = null;
887
+ return json(res, 200, { ok: true, removed });
888
+ }
851
889
  case '/setup': {
852
890
  // A visited page must not be able to navigate this into existence.
853
891
  if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
package/lib/c2s.mjs CHANGED
@@ -5,14 +5,26 @@
5
5
  // SAME helper the facade and admin surfaces use — this module re-implements
6
6
  // no persistence and no delivery, so one write path stays one.
7
7
  //
8
- // GETs are redirects: the pod's documents are the canonical actor and outbox,
9
- // and a second renderer here would only drift from them.
8
+ // GETs on the actor and outbox are redirects: the pod's documents are the
9
+ // canonical ones, and a second renderer here would only drift from them.
10
+ //
11
+ // The inbox is the exception, and has to be. Deliveries land in a container on
12
+ // the pod which the drain empties as it handles each item, so reading that
13
+ // container tells the owner only what has not been dealt with yet. What was
14
+ // actually received is whole only in the archive, so §5.2's "the owner can
15
+ // read their own inbox" is served from there, by this agent, to the owner
16
+ // alone.
10
17
 
11
18
  import * as social from './social.mjs';
12
19
  import * as wire from './wire.mjs';
13
20
 
14
21
  const MAX_BODY = 512 * 1024; // same ceiling the inbox drain enforces
15
22
 
23
+ // How many archived items one page of the inbox will read. A page is one
24
+ // month, and a month with more than this is served short rather than costing
25
+ // the pod an unbounded read; the log says when that happened.
26
+ const MAX_INBOX_PAGE = 500;
27
+
16
28
  // §6 names activities; anything else with a type is an object to wrap.
17
29
  const ACTIVITY_TYPES = new Set([
18
30
  'Create', 'Update', 'Delete', 'Follow', 'Like', 'Announce', 'Undo',
@@ -60,14 +72,102 @@ export class C2S {
60
72
  return iri ? this.store.getStatuses().find((s) => s.noteId === iri) : null;
61
73
  }
62
74
 
75
+ /** The months the archive holds, newest first. One container listing. */
76
+ async archiveMonths() {
77
+ const archive = this.agent.intake?.archive;
78
+ if (!archive) return [];
79
+ const { names } = await archive.list('');
80
+ return (names || [])
81
+ .map((n) => n.replace(/\/$/u, ''))
82
+ .filter((n) => /^\d{4}-\d{2}$/u.test(n))
83
+ .sort()
84
+ .reverse();
85
+ }
86
+
87
+ /**
88
+ * The owner's own inbox, §5.2. Paged by month because that is how the
89
+ * archive is stored, so a page costs one listing and a read per item and no
90
+ * page is dearer for another month being large.
91
+ */
92
+ async sendInbox(res, url) {
93
+ const id = `${this.urls.base}ap/inbox`;
94
+ const page = url?.searchParams?.get('page') || null;
95
+ let months;
96
+ try {
97
+ months = await this.archiveMonths();
98
+ } catch (e) {
99
+ return this.send(res, 502, { error: `the archive could not be read: ${e.message}` });
100
+ }
101
+
102
+ if (!page) {
103
+ if (!months.length && this.store.getConfig()?.archiveInbox === false) {
104
+ this.log('inbox read: nothing to show — this identity does not keep what it receives');
105
+ }
106
+ return this.send(res, 200, {
107
+ '@context': wire.AS_CTX, id, type: 'OrderedCollection',
108
+ ...(months.length ? { first: `${id}?page=${months[0]}` } : { orderedItems: [] }),
109
+ });
110
+ }
111
+ if (!/^\d{4}-\d{2}$/u.test(page)) {
112
+ return this.send(res, 400, { error: 'page names a month, written 2026-09' });
113
+ }
114
+
115
+ const archive = this.agent.intake?.archive;
116
+ let names = [];
117
+ try {
118
+ // The trailing slash matters: without it this names a document, not the
119
+ // container, and a pod answers about the wrong thing.
120
+ ({ names } = await archive.list(`${page}/`));
121
+ } catch (e) {
122
+ return this.send(res, 502, { error: `the archive could not be read: ${e.message}` });
123
+ }
124
+ const files = (names || []).filter((n) => n.endsWith('.json')).sort();
125
+ if (files.length > MAX_INBOX_PAGE) {
126
+ this.log(`inbox read: ${page} holds ${files.length} items; serving the first ${MAX_INBOX_PAGE}`);
127
+ }
128
+ const kept = [];
129
+ for (const file of files.slice(0, MAX_INBOX_PAGE)) {
130
+ // Read as written: these records are JSON-LD, and the default read asks
131
+ // turtle-first, which a server is free to answer with turtle.
132
+ const read = await archive.read(`${page}/${file}`, { accept: '*/*' });
133
+ if (!read?.ok || !read.body) continue;
134
+ try {
135
+ const record = JSON.parse(read.body);
136
+ // The record wraps the bytes as they arrived; the activity is those
137
+ // bytes, not a retelling of them.
138
+ kept.push({ at: record.receivedAt || '', activity: JSON.parse(record.raw) });
139
+ } catch { /* a record that will not parse is not one that can be served */ }
140
+ }
141
+ kept.sort((a, b) => String(b.at).localeCompare(String(a.at)));
142
+ const older = months.filter((m) => m < page)[0] || null;
143
+ return this.send(res, 200, {
144
+ '@context': wire.AS_CTX,
145
+ id: `${id}?page=${page}`,
146
+ type: 'OrderedCollectionPage',
147
+ partOf: id,
148
+ ...(older ? { next: `${id}?page=${older}` } : {}),
149
+ orderedItems: kept.map((k) => k.activity),
150
+ });
151
+ }
152
+
63
153
  async handle(req, res, pathname, url) { // eslint-disable-line no-unused-vars
64
- if (pathname !== '/ap/outbox' && pathname !== '/ap/actor') return false;
154
+ if (pathname !== '/ap/outbox' && pathname !== '/ap/actor' && pathname !== '/ap/inbox') return false;
65
155
  if (req.method === 'OPTIONS') {
66
- res.writeHead(204, { allow: 'GET, POST, OPTIONS' }); res.end(); return true;
156
+ res.writeHead(204, { allow: pathname === '/ap/inbox' ? 'GET, OPTIONS' : 'GET, POST, OPTIONS' });
157
+ res.end(); return true;
67
158
  }
68
159
  if (!this.agent.configured() || !this.urls) {
69
160
  return this.send(res, 409, { error: 'agent not configured' });
70
161
  }
162
+ if (pathname === '/ap/inbox') {
163
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
164
+ return this.send(res, 405, { error: "deliveries go to this actor's inbox on the pod, which the "
165
+ + 'actor document names; this address is the owner reading their own' });
166
+ }
167
+ const reader = await this.auth(req, pathname);
168
+ if (!reader.ok) return this.send(res, reader.status, { error: reader.error });
169
+ return this.sendInbox(res, url);
170
+ }
71
171
  if (req.method === 'GET' || req.method === 'HEAD') {
72
172
  // The pod's copy is the document; send the reader there.
73
173
  const target = pathname === '/ap/actor' ? this.urls.actor : this.urls.outbox;
package/lib/embed.mjs CHANGED
@@ -210,5 +210,10 @@ export async function startEmbeddedAgent({
210
210
  ]);
211
211
  };
212
212
 
213
- return { agent, handle, home, surface, host: authorities.host, stop };
213
+ // podHome and actorUrl are the identity's own locations on the pod. They are
214
+ // returned rather than rebuilt by the caller so the root name lives here.
215
+ return {
216
+ agent, handle, home, surface, host: authorities.host,
217
+ podHome: urls.home, actorUrl: urls.actor, stop,
218
+ };
214
219
  }
@@ -18,6 +18,7 @@
18
18
  import crypto from 'node:crypto';
19
19
  import { handleDelivery } from './gateway-core.mjs';
20
20
  import { readCapped } from './safefetch.mjs';
21
+ import { linkTargets, REL } from './links.mjs';
21
22
 
22
23
  // The one WebFinger document, spelled out here rather than imported from
23
24
  // wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
@@ -48,6 +49,24 @@ async function verifyPodToken(request, pathname, verifier) {
48
49
  // Is this WebID served by the claimed pod? A pod owner's WebID lives on the pod
49
50
  // origin — that is the whole proof: a token for a WebID under podHome could
50
51
  // only be minted by someone who controls that pod's identity provider.
52
+ // A pod that will not answer must not hold up the person opting in; without
53
+ // an answer the older check stands on its own.
54
+ const OWNER_LOOKUP_MS = 5_000;
55
+
56
+ /**
57
+ * Who the pod server says owns the pod. The server that hosts it is the
58
+ * authority on that, so when it answers, its answer decides. A server that
59
+ * says nothing leaves where the WebID lives as the only evidence there is.
60
+ */
61
+ async function podOwners(podBase, fetchImpl = fetch) {
62
+ try {
63
+ const res = await fetchImpl(podBase, {
64
+ method: 'HEAD', signal: AbortSignal.timeout(OWNER_LOOKUP_MS),
65
+ });
66
+ return linkTargets(res?.headers?.get?.('link'), REL.owner, podBase);
67
+ } catch { return []; }
68
+ }
69
+
51
70
  function webidUnderPod(webid, podHome) {
52
71
  try { return new URL(webid).origin === new URL(podHome).origin; } catch { return false; }
53
72
  }
@@ -285,7 +304,11 @@ export async function routeFront(request, ctx) {
285
304
  } catch { return j(400, { error: 'podBase is not a URL' }); }
286
305
  const webid = await verifyPodToken(request, pathname, ctx.verifier);
287
306
  if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
288
- if (!webid.startsWith(podBase)) {
307
+ // The pod's own server names its owner when it can. Where it does, that is
308
+ // the proof; where it does not, the WebID must at least live under the pod.
309
+ const owners = await podOwners(podBase, ctx.fetchImpl || fetch);
310
+ const proven = owners.length ? owners.includes(webid) : webid.startsWith(podBase);
311
+ if (!proven) {
289
312
  return j(403, { error: 'the token proves a different pod than the one you listed' });
290
313
  }
291
314
  if (action === 'opt-in') {
@@ -304,9 +327,9 @@ export async function routeFront(request, ctx) {
304
327
  return j(400, { error: 'action must be opt-in or opt-out' });
305
328
  }
306
329
 
307
- // The vendored Solid-OIDC browser library the signup page loads served
308
- // here because this function owns every path on the domain.
309
- if (pathname === '/solid-client-authn.bundle.js') {
330
+ // The vendored Solid-OIDC browser library the /run and /admin pages load
331
+ // served here because this function owns every path on the domain.
332
+ if (pathname === '/solid-oidc-client.js') {
310
333
  if (!ctx.authBundle) return notFound();
311
334
  return { status: 200, headers: { 'content-type': 'text/javascript' }, body: ctx.authBundle };
312
335
  }
package/lib/intake.mjs CHANGED
@@ -18,6 +18,7 @@ import * as $rdf from 'rdflib';
18
18
  import { USER_AGENT } from './ua.mjs';
19
19
  import { PUBLIC } from './wire.mjs';
20
20
  import { HTTP_TIMEOUT_MS, readCapped } from './safefetch.mjs';
21
+ import { linkTargets, REL } from './links.mjs';
21
22
  import { dropFollower } from './store.mjs';
22
23
 
23
24
  const RDF = $rdf.Namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#');
@@ -307,6 +308,24 @@ export class Intake {
307
308
  }
308
309
  }
309
310
 
311
+ /**
312
+ * Where this pod describes the services it offers. The pod says so on any
313
+ * response about one of its resources; the well-known path is only what a
314
+ * pod that says nothing has always used.
315
+ */
316
+ async _storageDescriptionUrl() {
317
+ try {
318
+ const head = await fetch(this.urls.base, {
319
+ method: 'HEAD',
320
+ headers: { 'user-agent': USER_AGENT },
321
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
322
+ });
323
+ const [found] = linkTargets(head.headers.get('link'), REL.storageDescription, this.urls.base);
324
+ if (found) return found;
325
+ } catch { /* the well-known path below */ }
326
+ return this.urls.base + '.well-known/solid';
327
+ }
328
+
310
329
  async _subscribeOnce() {
311
330
  // Reuse a channel we already have rather than asking for another one.
312
331
  const saved = this.store.read(CHANNEL_DOC, null);
@@ -314,13 +333,13 @@ export class Intake {
314
333
  this._openSocket(saved.receiveFrom, true);
315
334
  return;
316
335
  }
317
- const descRes = await fetch(this.urls.base + '.well-known/solid', {
336
+ const descUrl = await this._storageDescriptionUrl();
337
+ const descRes = await fetch(descUrl, {
318
338
  headers: { accept: 'text/turtle', 'user-agent': USER_AGENT },
319
339
  signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
320
340
  });
321
341
  // The service description is RDF; ask rdflib which subject is the
322
342
  // WebSocketChannel2023 service rather than pattern-matching the document.
323
- const descUrl = this.urls.base + '.well-known/solid';
324
343
  const g = $rdf.graph();
325
344
  try { $rdf.parse(await readCapped(descRes), g, descUrl, 'text/turtle'); }
326
345
  catch (e) { this.wsState = 'unavailable'; this.log(`service description unparsable (${e.message}) — polling only`); return; }
package/lib/links.mjs ADDED
@@ -0,0 +1,35 @@
1
+ // links.mjs — reading RFC 8288 Link headers.
2
+ //
3
+ // Solid says where a resource's access control lives, where a storage
4
+ // describes itself, and who owns a storage, by putting a link on the response.
5
+ // Working any of those out from the resource's own URL instead is exactly what
6
+ // the specs tell clients not to do, so this is the one place that reads them.
7
+
8
+ /**
9
+ * Every target a Link header gives for one relation, resolved against the URL
10
+ * the header came from. A header may carry several links, and one link may
11
+ * carry several relation names.
12
+ */
13
+ export function linkTargets(headerValue, rel, baseUrl) {
14
+ if (!headerValue) return [];
15
+ const wanted = String(rel).toLowerCase();
16
+ const out = [];
17
+ // Split on the commas BETWEEN links: one inside a URI has its closing angle
18
+ // bracket still ahead of it, and is left alone.
19
+ for (const part of String(headerValue).split(/,(?![^<]*>)/u)) {
20
+ const link = /^\s*<([^>]*)>\s*(.*)$/u.exec(part);
21
+ if (!link) continue;
22
+ const relParam = /(?:^|;)\s*rel\s*=\s*(?:"([^"]*)"|([^;"\s]+))/iu.exec(link[2]);
23
+ const names = (relParam?.[1] ?? relParam?.[2] ?? '').toLowerCase().split(/\s+/u);
24
+ if (!names.includes(wanted)) continue;
25
+ try { out.push(new URL(link[1], baseUrl).href); } catch { /* not a URL we can follow */ }
26
+ }
27
+ return out;
28
+ }
29
+
30
+ /** The relations this project follows. */
31
+ export const REL = {
32
+ acl: 'acl',
33
+ storageDescription: 'http://www.w3.org/ns/solid/terms#storageDescription',
34
+ owner: 'http://www.w3.org/ns/solid/terms#owner',
35
+ };