fedipod-server 0.6.0 → 0.8.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.
@@ -0,0 +1,257 @@
1
+ // acctfeed.mjs — polls each connected fediverse account's home timeline and
2
+ // notifications and merges them into the statuses index as kind 'acct'. View
3
+ // cache only: none of it is written to the pod, which holds followed and own
4
+ // content. Modeled on bskyfeed.mjs, which is modeled on tagfeed.mjs.
5
+ //
6
+ // A note is NOT re-fetched at its origin before being stored. The dereference
7
+ // rule guards material an unauthenticated stranger chose for us; this is the
8
+ // owner's own authenticated account answering with the result of the owner's
9
+ // own follow decisions there — the same trust anchor that lets bskyfeed skip
10
+ // it. A local block still applies, because that is the owner's decision.
11
+ //
12
+ // Config in fediacctfeed.json: { intervalMin, accounts: { <id>: marks } }.
13
+
14
+ import { sanitizeHtml } from './wire.mjs';
15
+
16
+ const DEFAULTS = { intervalMin: 5 };
17
+ const PER_SWEEP = 40;
18
+ const MAX_PER_ACCT = 150;
19
+ const MAX_ACCT_ENTRIES = 400;
20
+ const BACKOFF_MIN_MS = 5 * 60_000;
21
+ const BACKOFF_MAX_MS = 2 * 60 * 60_000;
22
+ // Mastodon allows 300 requests per five minutes per token. Four go per sweep,
23
+ // so the floor is only ever reached by something else using the same token.
24
+ const RATE_FLOOR = 20;
25
+
26
+ const MEDIA_TYPES = {
27
+ image: 'image/jpeg', gifv: 'image/gif', video: 'video/mp4', audio: 'audio/mpeg',
28
+ };
29
+
30
+ export class AcctFeed {
31
+ constructor({ store, accounts, log = console.log, onNotification = null }) {
32
+ Object.assign(this, { store, accounts, log, onNotification });
33
+ this.state = new Map(); // account id → { failures, quietUntil }
34
+ this.lastSweep = null;
35
+ this.lastAdded = 0;
36
+ }
37
+
38
+ config() { return { ...DEFAULTS, ...this.store.read('fediacctfeed.json', {}) }; }
39
+
40
+ setConfig(patch) {
41
+ const clean = {};
42
+ if (patch.intervalMin) clean.intervalMin = Math.max(1, Number(patch.intervalMin) || DEFAULTS.intervalMin);
43
+ this.store.write('fediacctfeed.json', { ...this.config(), ...clean });
44
+ this.stop();
45
+ this.start();
46
+ return this.config();
47
+ }
48
+
49
+ marks(id) { return this.config().accounts?.[id] || {}; }
50
+
51
+ setMarks(id, patch) {
52
+ const cfg = this.config();
53
+ this.store.write('fediacctfeed.json', {
54
+ ...cfg, accounts: { ...(cfg.accounts || {}), [id]: { ...(cfg.accounts?.[id] || {}), ...patch } },
55
+ });
56
+ }
57
+
58
+ start() {
59
+ this.stopped = false;
60
+ this.sweep().catch(e => this.log(`acctfeed: ${e.message}`));
61
+ const tick = () => {
62
+ this.timer = setTimeout(() => {
63
+ this.sweep()
64
+ .catch(e => this.log(`acctfeed: ${e.message}`))
65
+ .finally(() => { if (!this.stopped) tick(); });
66
+ }, Math.round(this.config().intervalMin * 60_000 * (0.85 + Math.random() * 0.3)));
67
+ this.timer.unref?.();
68
+ };
69
+ tick();
70
+ }
71
+
72
+ stop() { this.stopped = true; clearTimeout(this.timer); }
73
+
74
+ // Per account, not per feed: one server being down must not silence the rest.
75
+ _stateOf(id) {
76
+ if (!this.state.has(id)) this.state.set(id, { failures: 0, quietUntil: 0 });
77
+ return this.state.get(id);
78
+ }
79
+
80
+ _backOff(id, handle, status, retryAfter) {
81
+ const st = this._stateOf(id);
82
+ st.failures += 1;
83
+ const ladder = Math.min(BACKOFF_MIN_MS * 2 ** (st.failures - 1), BACKOFF_MAX_MS);
84
+ const wait = retryAfter || Math.round(ladder * (0.85 + Math.random() * 0.3));
85
+ st.quietUntil = Date.now() + wait;
86
+ this.log(`acctfeed: ${handle} ${status ? `answered ${status}` : 'did not answer'} — not asking again for ${Math.round(wait / 60_000)} min`);
87
+ }
88
+
89
+ // One author into the shared actor cache. `uri` is the ActivityPub id, so a
90
+ // person the pod already knows from its own inbox stays one actor here.
91
+ _rememberAuthor(a) {
92
+ const url = a?.uri || a?.url;
93
+ if (!url) return null;
94
+ if (!this.store.getActors()[url]) {
95
+ this.store.cacheActor(url, {
96
+ name: a.display_name || a.username, preferredUsername: a.username,
97
+ icon: a.avatar || null, type: a.bot ? 'Service' : 'Person',
98
+ });
99
+ }
100
+ return url;
101
+ }
102
+
103
+ // One Mastodon status into the statuses index. Never pre-checks for a
104
+ // duplicate: a repeat sighting is what carries the second account's
105
+ // provenance, and addStatus is the only thing that knows how to merge it.
106
+ _mirror(st, { acct, via = null, parents = null }) {
107
+ const noteId = st.uri;
108
+ if (!noteId) return false;
109
+ const actor = this._rememberAuthor(st.account);
110
+ if (!actor || this.store.isBlocked(actor)) return false;
111
+ const inReplyTo = st.in_reply_to_id ? parents?.get(String(st.in_reply_to_id)) : null;
112
+ const out = this.store.addStatus({
113
+ noteId, actor,
114
+ content: sanitizeHtml(st.content || ''),
115
+ published: st.created_at,
116
+ kind: 'acct',
117
+ sourceAccts: [{ acct, remoteId: String(st.id) }],
118
+ ...(st.url && st.url !== noteId ? { link: st.url } : {}),
119
+ ...(inReplyTo ? { inReplyTo } : {}),
120
+ ...(via ? { via } : {}),
121
+ ...(st.spoiler_text ? { spoiler: st.spoiler_text } : {}),
122
+ ...(st.media_attachments?.length ? {
123
+ attachments: st.media_attachments.map(m => ({
124
+ url: m.url, mediaType: MEDIA_TYPES[m.type] || 'image/jpeg', description: m.description || '',
125
+ })),
126
+ } : {}),
127
+ });
128
+ return !!out?.added;
129
+ }
130
+
131
+ // Mastodon threads by its own local ids, which mean nothing anywhere else.
132
+ // A parent we already hold from this same account can be resolved locally,
133
+ // and one we do not hold simply has no parent here rather than a fetch.
134
+ _parentMap(acct) {
135
+ const map = new Map();
136
+ for (const s of this.store.getStatuses()) {
137
+ for (const v of s.sourceAccts || []) {
138
+ if (v.acct === acct && v.remoteId) map.set(String(v.remoteId), s.noteId);
139
+ }
140
+ }
141
+ return map;
142
+ }
143
+
144
+ _rateGuard(id, handle, res) {
145
+ const left = Number(res.headers.get('x-ratelimit-remaining'));
146
+ if (!Number.isFinite(left) || left > RATE_FLOOR) return false;
147
+ const reset = Date.parse(res.headers.get('x-ratelimit-reset') || '');
148
+ const wait = Number.isFinite(reset) ? Math.max(0, reset - Date.now()) : BACKOFF_MIN_MS;
149
+ this._stateOf(id).quietUntil = Date.now() + wait;
150
+ this.log(`acctfeed: ${handle} has ${left} requests left — waiting ${Math.round(wait / 1000)}s`);
151
+ return true;
152
+ }
153
+
154
+ async sweep() {
155
+ const rows = (this.accounts?.list() || []).filter(r => r.token && r.enabled !== false);
156
+ if (!rows.length) return;
157
+ this.lastSweep = new Date().toISOString();
158
+ let added = 0;
159
+ // The 300ms debounce cannot coalesce a sweep that awaits between items —
160
+ // every fetch outlives it — so the whole sweep is one commit boundary.
161
+ // Without this, statuses.json is serialized whole once per post.
162
+ this.store.hold();
163
+ try {
164
+ for (const rec of rows) {
165
+ if (this.stopped) break;
166
+ added += await this._sweepOne(rec).catch((e) => {
167
+ this._backOff(rec.id, rec.handle, e.status || 0, null);
168
+ return 0;
169
+ });
170
+ }
171
+ this._prune();
172
+ } finally {
173
+ this.store.release();
174
+ }
175
+ this.lastAdded = added;
176
+ if (added) this.log(`acctfeed: +${added} from ${rows.length} connected account(s)`);
177
+ }
178
+
179
+ async _sweepOne(rec) {
180
+ const st = this._stateOf(rec.id);
181
+ if (st.quietUntil && Date.now() < st.quietUntil) return 0;
182
+ const marks = this.marks(rec.id);
183
+ const parents = this._parentMap(rec.id);
184
+ let added = 0;
185
+
186
+ const homeQ = marks.homeSinceId ? `&since_id=${encodeURIComponent(marks.homeSinceId)}` : '';
187
+ const homeRes = await this.accounts.api(rec.id, `/api/v1/timelines/home?limit=${PER_SWEEP}${homeQ}`);
188
+ if (this._rateGuard(rec.id, rec.handle, homeRes)) return 0;
189
+ const home = await homeRes.json().catch(() => []);
190
+ if (!Array.isArray(home)) throw new Error(`${rec.handle} answered with no timeline`);
191
+ for (const item of home) {
192
+ if (item?.reblog) {
193
+ // A boost: the inner post is the content, the booster is the carrier —
194
+ // the same envelope statusOrBoost already renders for our own timeline.
195
+ const via = this._rememberAuthor(item.account);
196
+ if (this._mirror(item.reblog, { acct: rec.id, via, parents })) added++;
197
+ } else if (item?.uri) {
198
+ if (this._mirror(item, { acct: rec.id, parents })) added++;
199
+ }
200
+ }
201
+ // Mastodon returns newest first, so the first row is the new mark.
202
+ // Ids are 19-digit snowflakes: opaque strings, never numbers.
203
+ if (home.length && home[0]?.id) this.setMarks(rec.id, { homeSinceId: String(home[0].id) });
204
+
205
+ const notifQ = marks.notifSinceId ? `&since_id=${encodeURIComponent(marks.notifSinceId)}` : '';
206
+ const notifRes = await this.accounts.api(rec.id, `/api/v1/notifications?limit=${PER_SWEEP}${notifQ}`);
207
+ if (this._rateGuard(rec.id, rec.handle, notifRes)) return added;
208
+ const notes = await notifRes.json().catch(() => []);
209
+ if (Array.isArray(notes)) {
210
+ for (const n of notes) {
211
+ if (!n?.account || String(n.account.id) === String(rec.accountId)) continue;
212
+ const actor = this._rememberAuthor(n.account);
213
+ if (!actor || this.store.isBlocked(actor)) continue;
214
+ // The post a favourite or boost is about is one of this account's own,
215
+ // so it is mirrored first — a notification pointing at nothing is a
216
+ // row the client drops.
217
+ if (n.status?.uri) this._mirror(n.status, { acct: rec.id, parents });
218
+ if (n.type === 'favourite' || n.type === 'reblog') {
219
+ this.store.addNotification({
220
+ type: n.type === 'favourite' ? 'favourite' : 'reblog',
221
+ actor, noteId: n.status?.uri, via: rec.id,
222
+ });
223
+ } else if (n.type === 'follow' || n.type === 'follow_request') {
224
+ this.store.addNotification({ type: 'follow', actor, via: rec.id });
225
+ await this.onNotification?.(n, { actor, acct: rec.id });
226
+ } else if (n.type === 'mention') {
227
+ this.store.addNotification({ type: 'mention', actor, noteId: n.status?.uri, via: rec.id });
228
+ await this.onNotification?.(n, { actor, acct: rec.id });
229
+ }
230
+ }
231
+ if (notes.length && notes[0]?.id) this.setMarks(rec.id, { notifSinceId: String(notes[0].id) });
232
+ }
233
+
234
+ st.failures = 0;
235
+ return added;
236
+ }
237
+
238
+ // Two caps. The per-account one keeps a busy account from crowding out a
239
+ // quiet one; the total keeps every connected account together from evicting
240
+ // the pod's own posts and the timeline it already had.
241
+ _prune() {
242
+ const all = this.store.getStatuses();
243
+ const mine = all.filter(s => s.kind === 'acct');
244
+ if (!mine.length) return;
245
+ const drop = new Set();
246
+ const seen = new Map();
247
+ for (const s of mine) {
248
+ const acct = s.sourceAccts?.[0]?.acct || '?';
249
+ const n = (seen.get(acct) || 0) + 1;
250
+ seen.set(acct, n);
251
+ if (n > MAX_PER_ACCT) drop.add(s.noteId);
252
+ }
253
+ for (const s of mine.slice(MAX_ACCT_ENTRIES)) drop.add(s.noteId);
254
+ if (!drop.size) return;
255
+ this.store.write('statuses.json', all.filter(s => !drop.has(s.noteId)));
256
+ }
257
+ }
package/lib/admin.mjs CHANGED
@@ -16,7 +16,7 @@ import os from 'node:os';
16
16
  import { fileURLToPath, pathToFileURL } from 'node:url';
17
17
  import { followHandle, followActor, unfollowActor, ejectFollower, retractAnnouncement,
18
18
  admitRequest, refuseRequest, resolveHandle, applyModeration, announceModeration } from './social.mjs';
19
- import { addRemoveActivity, webfingerHost } from './wire.mjs';
19
+ import { addRemoveActivity, webfingerHost , publicHandle } from './wire.mjs';
20
20
  import { MastoApi, hashPassword } from './mastoapi.mjs';
21
21
  import { C2S } from './c2s.mjs';
22
22
  import { makeC2sAuth } from './oidc-auth.mjs';
@@ -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';
@@ -39,6 +39,38 @@ const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '
39
39
  const { makeGate } = require(path.join(projectRoot, 'vendor/gate.cjs'));
40
40
 
41
41
  const PHANPY_DIR = path.join(projectRoot, 'phanpy/dist');
42
+ // The vendored client is upstream's build, byte for byte, so the integrity
43
+ // check can say so. The two things it needs changed happen on the way out
44
+ // instead of in the files.
45
+ //
46
+ // vite-plugin-pwa injects this registration into every page it builds. The
47
+ // worker it installs answers navigations from a precache and replays the
48
+ // headers stored at install time, so a CSP change can never reach a browser
49
+ // that already has one.
50
+ const SW_REGISTER = /<script id="vite-plugin-pwa:inline-sw">[\s\S]*?<\/script>/i;
51
+ // Removing it stops new installs. This replaces the worker itself, so the ones
52
+ // already out there clean up: browsers re-fetch sw.js on navigation and
53
+ // install what they find. No fetch handler on purpose — a worker without one
54
+ // never intercepts a request, so pages go straight to the network while it
55
+ // runs.
56
+ const SW_KILL = `self.addEventListener('install', () => self.skipWaiting());
57
+
58
+ self.addEventListener('activate', (event) => {
59
+ event.waitUntil((async () => {
60
+ for (const key of await caches.keys()) await caches.delete(key);
61
+ await self.registration.unregister();
62
+ for (const client of await self.clients.matchAll({ type: 'window' })) {
63
+ try { await client.navigate(client.url); } catch { /* tab will refresh on its own */ }
64
+ }
65
+ })());
66
+ });
67
+ `;
68
+ // Null rather than the original when the tag is gone: an upstream change that
69
+ // silently no-opped here would put the worker back, which is the whole thing
70
+ // this exists to prevent.
71
+ function stripSwRegistration(html) {
72
+ return SW_REGISTER.test(html) ? html.replace(SW_REGISTER, '') : null;
73
+ }
42
74
  const UI_DIR = path.join(projectRoot, 'ui'); // extra client dists: ui/<name>/ → /<name>/
43
75
  // Our own pages, kept out of ui/ for two reasons: a client dist dropped in
44
76
  // there under the same name would shadow them, and a group serves these and
@@ -80,17 +112,46 @@ const AGENT_VERSION = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package
80
112
  // /shutdown is here because stopping an agent that was never set up is exactly
81
113
  // the case it exists for; it is in LOCAL_ONLY_POSTS below, so it still answers
82
114
  // only to this machine.
83
- 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
+
117
+ // The page the other server's redirect lands on. Self-contained on purpose:
118
+ // the browser arrives here from somewhere else, and nothing may load from
119
+ // that somewhere.
120
+ function callbackPage(ok, msg) {
121
+ const esc = (s) => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
122
+ const head = ok ? 'Account connected' : 'Not connected';
123
+ return `<!doctype html>
124
+ <html lang="en"><head><meta charset="utf-8">
125
+ <meta name="viewport" content="width=device-width, initial-scale=1">
126
+ <title>${head}</title>
127
+ <style>
128
+ :root { color-scheme: light dark; }
129
+ body { font: 16px/1.7 system-ui, -apple-system, sans-serif; margin: 0;
130
+ padding: 2.5rem 2rem; background: #ffffff; color: #1a1a1a; }
131
+ main { max-width: 32rem; margin: 0 auto; }
132
+ h1 { font-size: 1.375rem; font-weight: 500; margin: 0 0 1rem; }
133
+ p { margin: 0 0 1rem; }
134
+ @media (prefers-color-scheme: dark) {
135
+ body { background: #16161a; color: #ececf0; }
136
+ }
137
+ </style></head>
138
+ <body><main>
139
+ <h1>${head}</h1>
140
+ <p>${esc(msg)}</p>
141
+ <p>You can close this tab and go back to FediPod.</p>
142
+ </main></body></html>
143
+ `;
144
+ }
84
145
  // Routes that manage a local agent process — spawning siblings, killing this
85
146
  // one, moving files on the machine, running setup in a browser. Inside a pod
86
147
  // server there is no such process and no such machine: identities come from
87
148
  // the server's own configuration, so these are not there to be found.
88
149
  const EMBEDDED_CUT = new Set(['/profiles', '/shutdown', '/new-actor', '/start-actor',
89
- '/state-move', '/setup', '/setup/check']);
150
+ '/state-move', '/setup', '/setup/check', '/setup/reset']);
90
151
  // AP_ALLOWED_HOSTS may name a tailnet host or a reverse-proxy domain. The
91
152
  // fediverse is welcome there; creating accounts and editing the record is for
92
153
  // whoever is sitting at the machine.
93
- const LOCAL_ONLY_POSTS = new Set(['/setup', '/setup/check', '/config', '/new-actor', '/start-actor', '/shutdown', '/state-move', '/atproto/connect', '/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']);
94
155
  // The identity itself. Changing any of these means a different actor at a
95
156
  // different address, which is a new setup, not an edit.
96
157
  const PERMANENT_CONFIG = ['handle', 'remotePod', 'issuer', 'root', 'kind'];
@@ -118,7 +179,10 @@ function inlineScriptHashes() {
118
179
  if (inlineHashes) return inlineHashes;
119
180
  inlineHashes = [];
120
181
  try {
121
- const html = fs.readFileSync(path.join(PHANPY_DIR, 'index.html'), 'utf8');
182
+ // The page as it is SERVED, not as it sits on disk: hashing the
183
+ // registration script we strip would allow a script nobody gets.
184
+ const raw = fs.readFileSync(path.join(PHANPY_DIR, 'index.html'), 'utf8');
185
+ const html = stripSwRegistration(raw) ?? raw;
122
186
  for (const m of html.matchAll(/<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/gi)) {
123
187
  const digest = crypto.createHash('sha256').update(m[1], 'utf8').digest('base64');
124
188
  inlineHashes.push(`'sha256-${digest}'`);
@@ -296,7 +360,23 @@ function sendFile(res, baseDir, rel, auth) {
296
360
  res.writeHead(403); res.end(); return true;
297
361
  }
298
362
  const ext = path.extname(real);
299
- const body = fs.readFileSync(real);
363
+ let body = fs.readFileSync(real);
364
+ // The vendored client, adjusted on the way out rather than in the files.
365
+ if (real.startsWith(fs.realpathSync(PHANPY_DIR))) {
366
+ if (path.basename(real) === 'sw.js') {
367
+ body = Buffer.from(SW_KILL);
368
+ } else if (ext === '.html') {
369
+ const stripped = stripSwRegistration(body.toString('utf8'));
370
+ if (stripped === null) {
371
+ console.error(`refusing to serve ${path.basename(real)}: no service-worker `
372
+ + 'registration to remove — the vendored client changed shape');
373
+ res.writeHead(500, { 'content-type': 'text/plain', ...securityHeaders(auth, false) });
374
+ res.end('the bundled client changed shape; refusing to serve it\n');
375
+ return true;
376
+ }
377
+ body = Buffer.from(stripped);
378
+ }
379
+ }
300
380
  // Our own pages are read straight off disk and change whenever the project
301
381
  // does. With no cache headers a browser is free to reuse them without
302
382
  // asking, so an edited page keeps rendering the old one and looks like the
@@ -490,7 +570,10 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
490
570
  // the handle, and the pod host its actor URL sits on.
491
571
  let address = null;
492
572
  if (live?.handle && live?.actor) {
493
- try { address = `${live.handle}@${new URL(live.actor).host}`; } catch { /* not a URL yet */ }
573
+ try {
574
+ const front = live.actor.match(/\/u\/([^/]+)\/ap\/actor\/?$/)?.[1];
575
+ address = `${front || live.handle}@${new URL(live.actor).host}`;
576
+ } catch { /* not a URL yet */ }
494
577
  }
495
578
  return {
496
579
  name, port, current,
@@ -508,6 +591,32 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
508
591
  if (req.method === 'GET' && p === '/blocks') return json(res, 200, agent.store.getBlocklist());
509
592
  if (req.method === 'GET' && p === '/log') return json(res, 200, { lines: agent.logLines(200) });
510
593
  if (req.method === 'GET' && p === '/deadletter') return json(res, 200, { items: agent.store.getDeadLetters() });
594
+ if (req.method === 'GET' && p === '/fediacct') {
595
+ return json(res, 200, { accounts: agent.fediaccts?.status() || [] });
596
+ }
597
+ // Where the other server sends the browser back. A GET, so it gets none
598
+ // of the POST branch's local-only protection for free and asks for its
599
+ // own — a code arriving from anywhere else is not the owner's.
600
+ if (req.method === 'GET' && p === '/fediacct/callback') {
601
+ if (!embedded && !allowed.isLocalRequest(req)) {
602
+ return json(res, 403, { error: 'connecting an account is available on this machine only' });
603
+ }
604
+ const q = new URL(req.url, 'https://x.invalid').searchParams;
605
+ const done = (ok, msg) => {
606
+ res.writeHead(ok ? 200 : 400, { 'content-type': 'text/html; charset=utf-8' });
607
+ res.end(callbackPage(ok, msg));
608
+ };
609
+ if (q.get('error')) return done(false, q.get('error_description') || q.get('error'));
610
+ if (!q.get('code') || !q.get('state')) return done(false, 'that sign-in came back incomplete');
611
+ try {
612
+ const row = await agent.fediaccts.complete({ state: q.get('state'), code: q.get('code') });
613
+ const cfg = agent.store.getConfig();
614
+ agent.store.setConfig({ ...cfg, fediAccounts: agent.fediaccts.roster() });
615
+ await agent.store.flush();
616
+ agent.restartAccts?.();
617
+ return done(true, `${row.handle} is connected.`);
618
+ } catch (e) { return done(false, e.message); }
619
+ }
511
620
  if (req.method === 'GET' && p === '/tagfeed') {
512
621
  return json(res, 200, agent.tagfeed
513
622
  ? { ...agent.tagfeed.config(), lastSweep: agent.tagfeed.lastSweep, lastAdded: agent.tagfeed.lastAdded }
@@ -568,6 +677,11 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
568
677
  if (!cfg) return json(res, 409, { error: 'agent not configured — set it up at /admin/setup/' });
569
678
  const urls = agent.urls || agent.publisher?.urls || null;
570
679
  const wfHost = urls ? new URL(urls.base).host : null;
680
+ // A fronted identity's address is its name AT THE FRONT — the front
681
+ // serves the actor under that name, whatever this pod calls it.
682
+ const address = cfg.gateway?.frontActor
683
+ ? `@${publicHandle(cfg)}@${new URL(cfg.gateway.frontActor).host}`
684
+ : (wfHost ? `@${cfg.handle}@${wfHost}` : null);
571
685
  return json(res, 200, {
572
686
  // permanent
573
687
  handle: cfg.handle, remotePod: cfg.remotePod, issuer: cfg.issuer,
@@ -578,7 +692,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
578
692
  // same way every other id is — computed here rather than in the page,
579
693
  // which has no business knowing how they are made.
580
694
  accountId: urls?.actor ? agent.store.idFor(urls.actor) : null,
581
- address: wfHost ? `@${cfg.handle}@${wfHost}` : null,
695
+ address,
582
696
  // editable
583
697
  name: cfg.name || null, summary: cfg.summary || null, icon: cfg.icon || null,
584
698
  image: cfg.image || null, fields: cfg.fields || [],
@@ -605,6 +719,10 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
605
719
  atproto: cfg.atproto
606
720
  ? { ...cfg.atproto, connected: !!agent.atproto?.connected() }
607
721
  : null,
722
+ // Connected fediverse accounts, from the credentials themselves
723
+ // rather than from config — a roster entry whose file was deleted
724
+ // should stop being listed, not linger.
725
+ fediAccounts: agent.fediaccts?.status() || [],
608
726
  origins: {
609
727
  loopback: publicOrigin
610
728
  || `https://localhost:${port}/`,
@@ -730,6 +848,34 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
730
848
  }
731
849
  // ---- setup, driven by the page at /admin/setup/ ----
732
850
  case '/setup/check': return json(res, 200, preflight(body));
851
+ // Discard a credential that never finished setup, so the account and
852
+ // pod can be entered again. The credential a CSS server mints is shown
853
+ // once, so a setup that stops after the mint (a wrong pod answers 401
854
+ // to the first write) leaves the form in "finish" mode with no way to
855
+ // re-enter what was wrong. This removes it locally and reopens the full
856
+ // form. It does NOT revoke server-side — that needs the account
857
+ // password (`fedipod revoke-credential`); the old credential is left on
858
+ // the account, revocable from its dashboard.
859
+ case '/setup/reset': {
860
+ if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
861
+ // A working identity is never swapped out this way — that is a
862
+ // teardown (`fedipod retire`), not a half-finished setup.
863
+ if (agent.configured()) {
864
+ return json(res, 409, { error: 'this home holds a working identity — retire it, do not reset' });
865
+ }
866
+ if (setupRun?.phase === 'running') {
867
+ return json(res, 409, { error: 'setup is running — let it finish or stop it first', phase: 'running' });
868
+ }
869
+ const home = agent.home;
870
+ if (!home) return json(res, 500, { error: 'this agent has no AP_HOME to reset' });
871
+ const removed = hasCredential(home);
872
+ if (removed) fs.rmSync(credentialPath(home), { force: true });
873
+ // Drop the pod handle too, so configured() cannot flicker true off a
874
+ // stale in-memory session while the fresh form is filled in.
875
+ agent.remote = null;
876
+ setupRun = null;
877
+ return json(res, 200, { ok: true, removed });
878
+ }
733
879
  case '/setup': {
734
880
  // A visited page must not be able to navigate this into existence.
735
881
  if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
@@ -1296,6 +1442,38 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1296
1442
  await agent.store.flush();
1297
1443
  return json(res, 200, { ok: true, atproto: { ...cfg.atproto, crossPost: !!body.crossPost } });
1298
1444
  }
1445
+ case '/fediacct/connect': {
1446
+ // Starts the sign-in at the other server. Loopback-only, and the
1447
+ // redirect returns to the origin the owner is actually using, not a
1448
+ // guess — a client reaches this agent by several names.
1449
+ if (!body.host) return json(res, 400, { error: 'the address of the server is required' });
1450
+ try {
1451
+ const { url } = await agent.fediaccts.begin({
1452
+ host: body.host, redirectUri: `https://${req.headers.host}/fediacct/callback`,
1453
+ });
1454
+ return json(res, 200, { ok: true, authorize: url });
1455
+ } catch (e) { return json(res, 400, { error: e.message }); }
1456
+ }
1457
+ case '/fediacct/disconnect': {
1458
+ if (!body.id) return json(res, 400, { error: 'id required' });
1459
+ if (!agent.fediaccts.remove(body.id)) return json(res, 404, { error: 'no such account' });
1460
+ const cfg = agent.store.getConfig();
1461
+ agent.store.setConfig({ ...cfg, fediAccounts: agent.fediaccts.roster() });
1462
+ await agent.store.flush();
1463
+ agent.restartAccts?.();
1464
+ return json(res, 200, { ok: true });
1465
+ }
1466
+ case '/fediacct': {
1467
+ // Non-secret settings only — today that is whether it is polled.
1468
+ if (!body.id) return json(res, 400, { error: 'id required' });
1469
+ const row = agent.fediaccts.setEnabled(body.id, !!body.enabled);
1470
+ if (!row) return json(res, 404, { error: 'no such account' });
1471
+ const cfg = agent.store.getConfig();
1472
+ agent.store.setConfig({ ...cfg, fediAccounts: agent.fediaccts.roster() });
1473
+ await agent.store.flush();
1474
+ agent.restartAccts?.();
1475
+ return json(res, 200, { ok: true, account: row });
1476
+ }
1299
1477
  case '/archive': {
1300
1478
  // Whether drained mail's original bytes are kept in the private
1301
1479
  // half's inbox-archive/. Absent means on.
@@ -1445,6 +1623,31 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1445
1623
  if (agent.publisher) agent.publisher.config.gateway = g;
1446
1624
  await agent.store.flush();
1447
1625
  };
1626
+ // Fronting renames the actor's ids; the signing key is the same
1627
+ // identity's and moves with it, wherever the key record lives.
1628
+ const restampKeys = async (actorId) => {
1629
+ try {
1630
+ const kp = path.join(agent.home, 'keys.json');
1631
+ const rec = JSON.parse(fs.readFileSync(kp, 'utf8'));
1632
+ if (rec.mintedFor) { rec.mintedFor = actorId; fs.writeFileSync(kp, JSON.stringify(rec)); return; }
1633
+ } catch { /* not local — try pod state */ }
1634
+ const podRec = agent.store.read('keys.json', null);
1635
+ if (podRec?.mintedFor) { podRec.mintedFor = actorId; agent.store.write('keys.json', podRec); await agent.store.flush(); }
1636
+ };
1637
+ const podActorId = () => {
1638
+ const base = cfg.remotePod.endsWith('/') ? cfg.remotePod : `${cfg.remotePod}/`;
1639
+ const root = cfg.root ? (cfg.root.endsWith('/') ? cfg.root : `${cfg.root}/`) : 'activitypods-js/';
1640
+ return `${base}${root}ap/actor`;
1641
+ };
1642
+ // The reply first, the restart a beat later — same shape as /update.
1643
+ const selfRestart = (why) => setTimeout(() => {
1644
+ log(why);
1645
+ if (process.env.INVOCATION_ID) { process.exit(1); return; } // systemd: Restart=on-failure respawns
1646
+ const child = spawn(process.execPath, process.argv.slice(1),
1647
+ { detached: true, stdio: 'ignore', env: process.env });
1648
+ child.unref();
1649
+ setTimeout(() => process.exit(0), 300);
1650
+ }, 200);
1448
1651
  if (body.action === 'configure') {
1449
1652
  if (!/^https:\/\/\S+$/.test(String(body.url || ''))) return json(res, 400, { error: 'gateway url must be https' });
1450
1653
  if (!/^https?:\/\/\S+$/.test(String(body.webId || ''))) return json(res, 400, { error: 'gateway webId must be a URL' });
@@ -1507,18 +1710,29 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1507
1710
  if (agent.embedded) {
1508
1711
  return json(res, 400, { error: 'this identity runs inside its pod server and has no portable credential — attach from a standalone agent' });
1509
1712
  }
1510
- const handle = String(body.handle || cfg.handle || '').toLowerCase().trim();
1713
+ const named = !!String(body.handle || '').trim();
1714
+ let handle = String(body.handle || cfg.handle || '').toLowerCase().trim();
1511
1715
  if (!handle) return json(res, 400, { error: 'a name at the gateway is required' });
1512
1716
  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
1717
  // Availability first, for a clean answer before anything is created.
1518
- const chk = await fetch(`${front}/api/handle?handle=${encodeURIComponent(handle)}`,
1718
+ const avail = async (h) => fetch(`${front}/api/handle?handle=${encodeURIComponent(h)}`,
1519
1719
  { headers: { accept: 'application/json' } }).then((r) => r.json()).catch(() => null);
1720
+ let chk = await avail(handle);
1520
1721
  if (!chk) return json(res, 502, { error: `${front} did not answer its handle check` });
1722
+ // An unnamed door name is plumbing nobody reads: walk to a free
1723
+ // variant instead of failing over a label the user never chose.
1724
+ if (!chk.available && !named) {
1725
+ for (let i = 2; i <= 9 && !chk.available; i++) {
1726
+ const cand = `${handle}${i}`;
1727
+ const c = await avail(cand);
1728
+ if (c?.available) { handle = cand; chk = c; }
1729
+ }
1730
+ }
1521
1731
  if (!chk.available) return json(res, 409, { error: chk.reason || `the name ${handle} is taken at ${front}` });
1732
+ const frontActor = `${front}/u/${handle}/ap/actor`;
1733
+ if (g.frontActor && (!fronted || g.frontActor !== frontActor)) {
1734
+ 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` });
1735
+ }
1522
1736
  const attach = await agent.remote.session.fetch(`${front}/api/attach`, {
1523
1737
  method: 'POST', headers: { 'content-type': 'application/json' },
1524
1738
  body: JSON.stringify({ handle, podHome: agent.urls.home, kind: cfg.kind || 'person', fronted }),
@@ -1535,21 +1749,35 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1535
1749
  if (!g.mode || g.mode === 'off') g.mode = 'shadow';
1536
1750
  await persist();
1537
1751
  // 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.
1752
+ // door. A front carries new ids, which are wired at startup — so a
1753
+ // fronted attach restarts this agent itself, the reply going out
1754
+ // first so it is not taken down with the process.
1540
1755
  if (!fronted) {
1541
1756
  await agent.publisher?.publishProfile();
1542
1757
  await agent.publisher?.publishGatewayPolicy?.().catch(() => {});
1758
+ return json(res, 200, { ok: true, mode: g.mode, url: g.url });
1543
1759
  }
1544
- return json(res, 200, { ok: true, mode: g.mode, url: g.url,
1545
- ...(fronted ? { frontActor: g.frontActor, address: d.address || null, restart: true } : {}) });
1760
+ await restampKeys(g.frontActor);
1761
+ json(res, 200, { ok: true, mode: g.mode, url: g.url,
1762
+ frontActor: g.frontActor, address: d.address || null, restarting: true });
1763
+ selfRestart('restarting to publish under the front');
1764
+ return;
1546
1765
  }
1547
1766
  if (body.action === 'forget') {
1548
1767
  const wasLocked = g.mode === 'locked';
1768
+ const wasFronted = !!g.frontActor;
1549
1769
  delete cfg.gateway; agent.store.setConfig(cfg);
1550
1770
  if (agent.publisher) agent.publisher.config.gateway = undefined;
1551
1771
  await agent.store.flush();
1552
1772
  if (wasLocked && inboxUrl) await agent.remote.setAcl(inboxUrl, ['Append']).catch(() => {});
1773
+ if (wasFronted) {
1774
+ // Going home renames every id back to the pod: key and process
1775
+ // follow, the same way attach came.
1776
+ await restampKeys(podActorId());
1777
+ json(res, 200, { ok: true, mode: 'off', forgotten: true, restarting: true });
1778
+ selfRestart('restarting under the pod\'s own ids');
1779
+ return;
1780
+ }
1553
1781
  await agent.publisher?.publishProfile(); // re-advertise the pod inbox
1554
1782
  return json(res, 200, { ok: true, mode: 'off', forgotten: true });
1555
1783
  }