fedipod-server 0.11.0 → 0.13.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.
Files changed (152) hide show
  1. package/README.md +22 -6
  2. package/dist/claims.d.ts +8 -0
  3. package/dist/claims.js +10 -0
  4. package/dist/handler.d.ts +13 -0
  5. package/dist/handler.js +56 -11
  6. package/dist/handler.jsonld +8 -0
  7. package/dist/store-pod.js +18 -4
  8. package/lib/{c2s.mjs → client/c2s.mjs} +10 -3
  9. package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
  10. package/lib/client/masto/accounts.mjs +264 -0
  11. package/lib/client/masto/body.mjs +69 -0
  12. package/lib/client/masto/index.mjs +183 -0
  13. package/lib/client/masto/instance.mjs +104 -0
  14. package/lib/client/masto/media.mjs +133 -0
  15. package/lib/client/masto/oauth.mjs +599 -0
  16. package/lib/client/masto/render.mjs +459 -0
  17. package/lib/client/masto/statuses.mjs +331 -0
  18. package/lib/client/masto/timelines.mjs +316 -0
  19. package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
  20. package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
  21. package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
  22. package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
  23. package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
  24. package/lib/{import.mjs → connections/import.mjs} +1 -1
  25. package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +3 -3
  26. package/lib/connections/vault.mjs +114 -0
  27. package/lib/core/as2.mjs +170 -0
  28. package/lib/core/contexts/activitystreams.json +379 -0
  29. package/lib/core/contexts/did-v1.json +57 -0
  30. package/lib/core/contexts/fep-5711.json +36 -0
  31. package/lib/core/contexts/gotosocial.json +86 -0
  32. package/lib/core/contexts/identity-v1.json +152 -0
  33. package/lib/core/contexts/index.mjs +45 -0
  34. package/lib/core/contexts/join-lemmy.json +33 -0
  35. package/lib/core/contexts/joinmastodon.json +28 -0
  36. package/lib/core/contexts/map.json +16 -0
  37. package/lib/core/contexts/miscellany.json +19 -0
  38. package/lib/core/contexts/schemaorg.json +8845 -0
  39. package/lib/core/contexts/security-data-integrity-v1.json +78 -0
  40. package/lib/core/contexts/security-data-integrity-v2.json +81 -0
  41. package/lib/core/contexts/security-multikey-v1.json +35 -0
  42. package/lib/core/contexts/security-v1.json +74 -0
  43. package/lib/core/contexts/webfinger.json +10 -0
  44. package/lib/{deliver.mjs → core/deliver.mjs} +2 -2
  45. package/lib/core/graphview.mjs +269 -0
  46. package/lib/core/intake/activities.mjs +437 -0
  47. package/lib/core/intake/activity.mjs +240 -0
  48. package/lib/core/intake/channel.mjs +144 -0
  49. package/lib/core/intake/group.mjs +222 -0
  50. package/lib/core/intake/index.mjs +629 -0
  51. package/lib/core/intake/notes.mjs +288 -0
  52. package/lib/core/intake/verify.mjs +142 -0
  53. package/lib/{keys.mjs → core/keys.mjs} +1 -1
  54. package/lib/core/publisher/collections.mjs +229 -0
  55. package/lib/core/publisher/index.mjs +421 -0
  56. package/lib/core/publisher/notes.mjs +188 -0
  57. package/lib/core/publisher/questions.mjs +233 -0
  58. package/lib/core/publisher/restore.mjs +199 -0
  59. package/lib/core/shapes/activitystreams.ttl +129 -0
  60. package/lib/core/shapes/index.mjs +107 -0
  61. package/lib/core/shapes/shapes-text.mjs +13 -0
  62. package/lib/{social.mjs → core/social.mjs} +2 -2
  63. package/lib/{store.mjs → core/store.mjs} +4 -0
  64. package/lib/{wire.mjs → core/wire.mjs} +2 -2
  65. package/lib/device/admin/index.mjs +13 -0
  66. package/lib/device/admin/origins.mjs +35 -0
  67. package/lib/device/admin/routes/connections.mjs +144 -0
  68. package/lib/device/admin/routes/gateway.mjs +199 -0
  69. package/lib/device/admin/routes/lifecycle.mjs +191 -0
  70. package/lib/device/admin/routes/owner.mjs +322 -0
  71. package/lib/device/admin/routes/setup.mjs +393 -0
  72. package/lib/device/admin/routes/social.mjs +188 -0
  73. package/lib/device/admin/server.mjs +95 -0
  74. package/lib/device/admin/static.mjs +244 -0
  75. package/lib/device/admin/surface.mjs +274 -0
  76. package/lib/device/cli/commands/account.mjs +586 -0
  77. package/lib/device/cli/commands/run.mjs +278 -0
  78. package/lib/device/cli/commands/service.mjs +221 -0
  79. package/lib/device/cli/commands/setup.mjs +410 -0
  80. package/lib/device/cli/commands/state.mjs +559 -0
  81. package/lib/device/cli/context.mjs +288 -0
  82. package/lib/{migrate.mjs → device/migrate.mjs} +1 -1
  83. package/lib/{remote.mjs → device/remote.mjs} +3 -3
  84. package/lib/{setup.mjs → device/setup.mjs} +3 -3
  85. package/lib/{update.mjs → device/update.mjs} +1 -1
  86. package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
  87. package/lib/{front-core.mjs → gateway/front-core.mjs} +3 -3
  88. package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +1 -1
  89. package/lib/{httpsig.mjs → gateway/httpsig.mjs} +1 -1
  90. package/lib/server/embed.mjs +405 -0
  91. package/lib/{links.mjs → shared/links.mjs} +1 -1
  92. package/lib/{ua.mjs → shared/ua.mjs} +1 -1
  93. package/package.json +1 -1
  94. package/run-agent.mjs +33 -25
  95. package/web/admin/actors.js +145 -0
  96. package/web/admin/common.js +23 -0
  97. package/web/admin/connections.js +112 -0
  98. package/web/admin/gateway.js +111 -0
  99. package/web/admin/group.js +258 -0
  100. package/web/admin/index.html +7 -1
  101. package/web/admin/record.js +378 -0
  102. package/web/admin/setup/index.html +1 -0
  103. package/web/admin/setup/setup.js +2 -13
  104. package/web/admin/upkeep.js +170 -0
  105. package/web/app/README.md +6 -6
  106. package/web/app/admin-facade.mjs +3 -3
  107. package/web/app/agent.mjs +14 -16
  108. package/web/app/atproto-browser.mjs +1 -1
  109. package/web/app/boot.mjs +2 -3
  110. package/web/app/deliver-relay.mjs +1 -1
  111. package/web/app/dist/boot.js +22 -3
  112. package/web/app/dist/boot.js.map +2 -2
  113. package/web/app/dist/sw.js +21913 -5446
  114. package/web/app/dist/sw.js.map +4 -4
  115. package/web/app/fediacct-browser.mjs +1 -1
  116. package/web/app/keys-browser.mjs +27 -4
  117. package/web/app/shims/shapes-text.mjs +8 -0
  118. package/web/app/signup.mjs +2 -3
  119. package/web/app/site/admin/actors.js +145 -0
  120. package/web/app/site/admin/common.js +23 -0
  121. package/web/app/site/admin/connections.js +112 -0
  122. package/web/app/site/admin/gateway.js +111 -0
  123. package/web/app/site/admin/group.js +258 -0
  124. package/web/app/site/admin/index.html +7 -1
  125. package/web/app/site/admin/record.js +378 -0
  126. package/web/app/site/admin/setup/index.html +1 -0
  127. package/web/app/site/admin/setup/setup.js +2 -13
  128. package/web/app/site/admin/upkeep.js +170 -0
  129. package/web/app/site/boot.js +22 -3
  130. package/web/app/site/sw.js +21913 -5446
  131. package/web/app/sw-src.mjs +17 -2
  132. package/lib/admin.mjs +0 -1913
  133. package/lib/embed.mjs +0 -220
  134. package/lib/intake.mjs +0 -1981
  135. package/lib/mastoapi.mjs +0 -2284
  136. package/lib/publisher.mjs +0 -1192
  137. package/web/admin/admin.js +0 -1181
  138. package/web/app/site/admin/admin.js +0 -1181
  139. /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
  140. /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
  141. /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
  142. /package/lib/{lease.mjs → core/lease.mjs} +0 -0
  143. /package/lib/{polls.mjs → core/polls.mjs} +0 -0
  144. /package/lib/{proof.mjs → core/proof.mjs} +0 -0
  145. /package/lib/{storage.mjs → core/storage.mjs} +0 -0
  146. /package/lib/{account.mjs → device/account.mjs} +0 -0
  147. /package/lib/{certs.mjs → device/certs.mjs} +0 -0
  148. /package/lib/{export-collections.mjs → device/export-collections.mjs} +0 -0
  149. /package/lib/{home.mjs → device/home.mjs} +0 -0
  150. /package/lib/{ports.mjs → device/ports.mjs} +0 -0
  151. /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
  152. /package/lib/{safefetch.mjs → shared/safefetch.mjs} +0 -0
@@ -0,0 +1,199 @@
1
+ // gateway.mjs — the inbox gateway lifecycle: configure, mode, check, attach
2
+ // through a front, forget.
3
+
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { spawn } from 'node:child_process';
8
+
9
+ export async function post(p, body, ctx, req, res) { // eslint-disable-line no-unused-vars
10
+ const { agent, log, allowed, embedded, port, publicOrigin, versionOnDisk, isGroup } = ctx; // eslint-disable-line no-unused-vars
11
+ const json = (res, status, obj) => { ctx.json(res, status, obj); return true; };
12
+ switch (p) {
13
+ // The inbox gateway lifecycle (opt-in). `configure` points at a
14
+ // deployed gateway (and reveals the HMAC secret once, to paste into
15
+ // its env); `mode` walks off→shadow→trust→locked and back, each step
16
+ // reversible; `forget` clears it and restores the pod inbox.
17
+ case '/gateway': {
18
+ const cfg = { ...agent.store.getConfig() };
19
+ const g = { ...(cfg.gateway || {}) };
20
+ const inboxUrl = agent.publisher?.urls?.inbox;
21
+ // Availability is a read: answered before the lease takeover below,
22
+ // which every real gateway change does want.
23
+ if (body.action === 'check') {
24
+ const front = String(body.front || '').replace(/\/+$/, '');
25
+ try { new URL(front); } catch { return json(res, 400, { error: 'front must be a gateway origin URL' }); }
26
+ const chk = await fetch(`${front}/api/handle?handle=${encodeURIComponent(String(body.handle || '').toLowerCase())}`,
27
+ { headers: { accept: 'application/json' } }).then((r) => r.json()).catch(() => null);
28
+ if (!chk) return json(res, 502, { error: `${front} did not answer its handle check` });
29
+ return json(res, 200, { available: !!chk.available, reason: chk.reason || null });
30
+ }
31
+ await agent.requestTakeover?.();
32
+ const persist = async () => {
33
+ cfg.gateway = g; agent.store.setConfig(cfg);
34
+ if (agent.publisher) agent.publisher.config.gateway = g;
35
+ await agent.store.flush();
36
+ };
37
+ // Fronting renames the actor's ids; the signing key is the same
38
+ // identity's and moves with it, wherever the key record lives.
39
+ const restampKeys = async (actorId) => {
40
+ try {
41
+ const kp = path.join(agent.home, 'keys.json');
42
+ const rec = JSON.parse(fs.readFileSync(kp, 'utf8'));
43
+ if (rec.mintedFor) { rec.mintedFor = actorId; fs.writeFileSync(kp, JSON.stringify(rec)); return; }
44
+ } catch { /* not local — try pod state */ }
45
+ const podRec = agent.store.read('keys.json', null);
46
+ if (podRec?.mintedFor) { podRec.mintedFor = actorId; agent.store.write('keys.json', podRec); await agent.store.flush(); }
47
+ };
48
+ const podActorId = () => {
49
+ const base = cfg.remotePod.endsWith('/') ? cfg.remotePod : `${cfg.remotePod}/`;
50
+ const root = cfg.root ? (cfg.root.endsWith('/') ? cfg.root : `${cfg.root}/`) : 'activitypods-js/';
51
+ return `${base}${root}ap/actor`;
52
+ };
53
+ // The reply first, the restart a beat later — same shape as /update.
54
+ const selfRestart = (why) => setTimeout(() => {
55
+ log(why);
56
+ if (process.env.INVOCATION_ID) { process.exit(1); return; } // systemd: Restart=on-failure respawns
57
+ const child = spawn(process.execPath, process.argv.slice(1),
58
+ { detached: true, stdio: 'ignore', env: process.env });
59
+ child.unref();
60
+ setTimeout(() => process.exit(0), 300);
61
+ }, 200);
62
+ if (body.action === 'configure') {
63
+ if (!/^https:\/\/\S+$/.test(String(body.url || ''))) return json(res, 400, { error: 'gateway url must be https' });
64
+ if (!/^https?:\/\/\S+$/.test(String(body.webId || ''))) return json(res, 400, { error: 'gateway webId must be a URL' });
65
+ g.url = String(body.url); g.webId = String(body.webId);
66
+ g.hmacSecret = g.hmacSecret || crypto.randomBytes(32).toString('base64');
67
+ g.mode = g.mode || 'off';
68
+ // The fronted actor URL, for a multi-user front that gives this
69
+ // identity a @name@front handle: the agent then publishes and signs
70
+ // under it. Optional; setting it on a published identity renames
71
+ // every id (a Move), so it belongs at setup or a fresh identity.
72
+ if ('frontActor' in body) {
73
+ const fa = String(body.frontActor || '');
74
+ if (fa && !/^https:\/\/\S+\/ap\/actor$/.test(fa)) {
75
+ return json(res, 400, { error: 'frontActor must be an https …/ap/actor URL' });
76
+ }
77
+ g.frontActor = fa || undefined;
78
+ }
79
+ await persist();
80
+ // The one time the secret is returned: the operator pastes it into
81
+ // the gateway's deploy env. /gateway GET never reveals it again.
82
+ return json(res, 200, { ok: true, mode: g.mode, url: g.url, webId: g.webId, hmacSecret: g.hmacSecret });
83
+ }
84
+ if (body.action === 'mode') {
85
+ const target = body.mode;
86
+ if (!['off', 'shadow', 'trust', 'locked'].includes(target)) {
87
+ return json(res, 400, { error: 'mode must be off, shadow, trust or locked' });
88
+ }
89
+ if (target !== 'off' && !g.url) return json(res, 400, { error: 'point at a gateway first (action: configure)' });
90
+ // Locking needs the door's own WebID to name in the ACL. Without it
91
+ // the mode used to persist and the ACL be written for an undefined
92
+ // agent, leaving config and pod disagreeing about who may write.
93
+ if (target === 'locked' && !g.webId) {
94
+ return json(res, 400, {
95
+ error: 'locked needs the gateway\'s WebID — set it with action: configure, webId: <door-webid>',
96
+ });
97
+ }
98
+ const prev = g.mode || 'off';
99
+ // The ACL first: a lock that fails must not leave the mode recorded
100
+ // as locked while the pod still accepts anyone's writes.
101
+ if (target === 'locked') await agent.publisher?.lockInboxToGateway(g.webId);
102
+ else if (prev === 'locked' && inboxUrl) await agent.remote.setAcl(inboxUrl, ['Append']);
103
+ g.mode = target;
104
+ await persist();
105
+ // Advertisement flips only when crossing the off boundary (the actor
106
+ // doc's inbox changes → publishProfile republishes, digest-gated).
107
+ if ((prev === 'off') !== (target === 'off')) await agent.publisher?.publishProfile();
108
+ else if (target !== 'off') await agent.publisher?.publishGatewayPolicy().catch(() => {});
109
+ return json(res, 200, { ok: true, mode: g.mode });
110
+ }
111
+ // Attach through a multi-user front (fedipod.net and kin): the agent
112
+ // proves the pod with its own credential — no browser, no password —
113
+ // and the front answers with the door and the receipt secret.
114
+ if (body.action === 'attach') {
115
+ const front = String(body.front || '').replace(/\/+$/, '');
116
+ let fu;
117
+ try { fu = new URL(front); } catch { return json(res, 400, { error: 'front must be the gateway origin, like https://fedipod.net' }); }
118
+ if (fu.protocol !== 'https:' && !/^(localhost|127\.0\.0\.1)$|\.localhost$/.test(fu.hostname)) {
119
+ return json(res, 400, { error: 'front must be https' });
120
+ }
121
+ if (agent.embedded) {
122
+ return json(res, 400, { error: 'this identity runs inside its pod server and has no portable credential — attach from a standalone agent' });
123
+ }
124
+ const named = !!String(body.handle || '').trim();
125
+ let handle = String(body.handle || cfg.handle || '').toLowerCase().trim();
126
+ if (!handle) return json(res, 400, { error: 'a name at the gateway is required' });
127
+ const fronted = body.fronted === true;
128
+ // Availability first, for a clean answer before anything is created.
129
+ const avail = async (h) => fetch(`${front}/api/handle?handle=${encodeURIComponent(h)}`,
130
+ { headers: { accept: 'application/json' } }).then((r) => r.json()).catch(() => null);
131
+ let chk = await avail(handle);
132
+ if (!chk) return json(res, 502, { error: `${front} did not answer its handle check` });
133
+ // An unnamed door name is plumbing nobody reads: walk to a free
134
+ // variant instead of failing over a label the user never chose.
135
+ if (!chk.available && !named) {
136
+ for (let i = 2; i <= 9 && !chk.available; i++) {
137
+ const cand = `${handle}${i}`;
138
+ const c = await avail(cand);
139
+ if (c?.available) { handle = cand; chk = c; }
140
+ }
141
+ }
142
+ if (!chk.available) return json(res, 409, { error: chk.reason || `the name ${handle} is taken at ${front}` });
143
+ const frontActor = `${front}/u/${handle}/ap/actor`;
144
+ if (g.frontActor && (!fronted || g.frontActor !== frontActor)) {
145
+ 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` });
146
+ }
147
+ const attach = await agent.remote.session.fetch(`${front}/api/attach`, {
148
+ method: 'POST', headers: { 'content-type': 'application/json' },
149
+ body: JSON.stringify({ handle, podHome: agent.urls.home, kind: cfg.kind || 'person', fronted }),
150
+ }).catch(() => null);
151
+ if (!attach) return json(res, 502, { error: `${front} did not answer the attach` });
152
+ const d = await attach.json().catch(() => ({}));
153
+ if (attach.status !== 201) {
154
+ return json(res, attach.status >= 400 && attach.status < 500 ? attach.status : 502,
155
+ { error: d.error || `attach failed (HTTP ${attach.status})` });
156
+ }
157
+ g.url = String(d.doorInbox || `${front}/u/${handle}/ap/inbox/`);
158
+ if (d.hmacSecret) g.hmacSecret = String(d.hmacSecret);
159
+ if (fronted) g.frontActor = String(d.frontActor || frontActor);
160
+ if (!g.mode || g.mode === 'off') g.mode = 'shadow';
161
+ await persist();
162
+ // Inbox-only applies live: the actor republishes advertising the
163
+ // door. A front carries new ids, which are wired at startup — so a
164
+ // fronted attach restarts this agent itself, the reply going out
165
+ // first so it is not taken down with the process.
166
+ if (!fronted) {
167
+ await agent.publisher?.publishProfile();
168
+ await agent.publisher?.publishGatewayPolicy?.().catch(() => {});
169
+ return json(res, 200, { ok: true, mode: g.mode, url: g.url });
170
+ }
171
+ await restampKeys(g.frontActor);
172
+ json(res, 200, { ok: true, mode: g.mode, url: g.url,
173
+ frontActor: g.frontActor, address: d.address || null, restarting: true });
174
+ selfRestart('restarting to publish under the front');
175
+ return true;
176
+ }
177
+ if (body.action === 'forget') {
178
+ const wasLocked = g.mode === 'locked';
179
+ const wasFronted = !!g.frontActor;
180
+ delete cfg.gateway; agent.store.setConfig(cfg);
181
+ if (agent.publisher) agent.publisher.config.gateway = undefined;
182
+ await agent.store.flush();
183
+ if (wasLocked && inboxUrl) await agent.remote.setAcl(inboxUrl, ['Append']).catch(() => {});
184
+ if (wasFronted) {
185
+ // Going home renames every id back to the pod: key and process
186
+ // follow, the same way attach came.
187
+ await restampKeys(podActorId());
188
+ json(res, 200, { ok: true, mode: 'off', forgotten: true, restarting: true });
189
+ selfRestart('restarting under the pod\'s own ids');
190
+ return true;
191
+ }
192
+ await agent.publisher?.publishProfile(); // re-advertise the pod inbox
193
+ return json(res, 200, { ok: true, mode: 'off', forgotten: true });
194
+ }
195
+ return json(res, 400, { error: 'action must be configure, mode, check, attach or forget' });
196
+ }
197
+ default: return false;
198
+ }
199
+ }
@@ -0,0 +1,191 @@
1
+ // lifecycle.mjs — what happens to the identity over time: draining and
2
+ // pruning the inbox, rebuilding posts, republishing, parking and reviving,
3
+ // rotating the key, moving, aliases, importing, retiring.
4
+
5
+ import { resolveHandle } from '../../../core/social.mjs';
6
+ import { webfingerHost } from '../../../core/wire.mjs';
7
+ import { normalizeImport, IMPORT_KINDS } from '../../../connections/import.mjs';
8
+
9
+ export async function post(p, body, ctx, req, res) { // eslint-disable-line no-unused-vars
10
+ const { agent, log, allowed, embedded, port, publicOrigin, versionOnDisk, isGroup } = ctx; // eslint-disable-line no-unused-vars
11
+ const json = (res, status, obj) => { ctx.json(res, status, obj); return true; };
12
+ switch (p) {
13
+ // Both of these DELETE from the pod's inbox, which is the one thing the
14
+ // lease exists to keep to a single agent. A viewer must claim it first
15
+ // and give up if it cannot — the facade has refused viewer writes since
16
+ // multi-device landed, and these two were simply missed.
17
+ case '/drain': {
18
+ if (!await agent.requestTakeover?.()) {
19
+ return json(res, 503, { error: 'another agent is active for this pod — it is doing the draining' });
20
+ }
21
+ await agent.intake.drain();
22
+ return json(res, 200, agent.status());
23
+ }
24
+ // The owner's answer to "your inbox is very full". Never automatic:
25
+ // discarding someone's mail is their call, not the agent's.
26
+ case '/inbox/prune': {
27
+ if (!body.before) return json(res, 400, { error: 'before (a date) required' });
28
+ if (!await agent.requestTakeover?.()) {
29
+ return json(res, 503, { error: 'another agent is active for this pod — discard from that one' });
30
+ }
31
+ return json(res, 200, await agent.intake.prune({
32
+ before: body.before,
33
+ ...(body.keepConcerning ? { keepConcerning: true } : {}),
34
+ }));
35
+ }
36
+ // Put back the posts a lost or restored machine no longer knows about,
37
+ // from what the pod still serves. It writes the statuses store, which is
38
+ // the lease's business, so it refuses the same way the drain does.
39
+ case '/rebuild': {
40
+ if (!await agent.requestTakeover?.()) {
41
+ return json(res, 503, { error: 'another agent is active for this pod — rebuild from that one' });
42
+ }
43
+ return json(res, 200, await agent.publisher.rebuildStatuses({ fromNotes: !!body.fromNotes }));
44
+ }
45
+ case '/publish-profile': {
46
+ // The explicit "republish now" control. Asking for it IS the reason.
47
+ const r = await agent.publisher.publishProfile({ force: true });
48
+ return json(res, 200, { ok: true, ...(r?.unreachable?.length ? { unreachable: r.unreachable } : {}) });
49
+ }
50
+ // ---- lifecycle: what the CLI calls park | revive | rotate-key | retire ----
51
+ // Each claims the lease first, for the same reason /config does: someone
52
+ // acting on this page outranks an idle active agent on another device.
53
+ // Asking first is the page's job — see the warnings in its markup.
54
+ case '/park': {
55
+ await agent.requestTakeover?.();
56
+ return json(res, 200, { ok: true, ...await agent.park() });
57
+ }
58
+ case '/revive': {
59
+ await agent.requestTakeover?.();
60
+ return json(res, 200, { ok: true, ...await agent.revive() });
61
+ }
62
+ case '/rotate-key': {
63
+ await agent.requestTakeover?.();
64
+ const r = await agent.rotateKey();
65
+ return json(res, 200, { ok: true, changed: !!r?.changed });
66
+ }
67
+ // Hand the identity on. Federated and effectively one-way: the Move
68
+ // tells every follower's server to migrate them, and the actor is left
69
+ // advertising movedTo so the old handle redirects. Same typed-handle
70
+ // interlock as retire — a stray click cannot produce it.
71
+ case '/move': {
72
+ if (!body.target) return json(res, 400, { error: 'target required' });
73
+ if (!body.confirm || body.confirm !== agent.store.getConfig()?.handle) {
74
+ return json(res, 400, { error: 'type the handle to confirm' });
75
+ }
76
+ await agent.requestTakeover?.();
77
+ // A handle is not an actor URI, and the Move's target has to be one or
78
+ // the far side has nothing to migrate anyone to. The CLI resolves it
79
+ // the same way; doing it here rather than in the page keeps the one
80
+ // WebFinger lookup on the side that already knows how.
81
+ let target = String(body.target).trim();
82
+ if (!/^https?:\/\//.test(target)) {
83
+ const doc = await resolveHandle(agent, target);
84
+ if (!doc?.id) return json(res, 400, { error: `could not resolve ${target}` });
85
+ target = doc.id;
86
+ }
87
+ return json(res, 200, { ok: true, ...await agent.moveTo(target) });
88
+ }
89
+ // The inbound half of a migration: list an old account elsewhere in
90
+ // this actor's alsoKnownAs, which is what the old server checks for
91
+ // before it will send a Move here. Whatever is entered is resolved to
92
+ // the account's canonical id — the check on the far side is an exact
93
+ // string match, so a pasted profile URL stored as typed would fail it.
94
+ case '/alias': {
95
+ const urls = agent.publisher?.urls;
96
+ if (!urls) return json(res, 409, { error: 'agent not connected yet' });
97
+ if (!body.add && !body.remove) return json(res, 400, { error: 'add or remove required' });
98
+ await agent.requestTakeover?.();
99
+ const cfg = { ...agent.store.getConfig() };
100
+ const aliases = [...(cfg.aliases || [])];
101
+ if (body.add) {
102
+ // A Move target nobody can resolve is a landing pad nobody lands on.
103
+ if (!webfingerHost(urls.base) && !cfg.gateway?.frontActor) {
104
+ return json(res, 400, {
105
+ error: 'this pod is a path on a shared host, so WebFinger cannot answer for it '
106
+ + '— other servers could never resolve this account as a Move target',
107
+ });
108
+ }
109
+ const input = String(body.add).trim().replace(/^@/, '');
110
+ let doc = /^https?:\/\//.test(input)
111
+ ? await agent.intake.fetchAP(input).catch(() => null)
112
+ : await resolveHandle(agent, input).catch(() => null);
113
+ // A URL may answer with any id it likes; before that id is stored
114
+ // it must vouch for itself — the intake's verify-by-deref.
115
+ if (doc?.id && /^https?:\/\//.test(input) && doc.id !== input) {
116
+ const own = await agent.intake.fetchAP(doc.id).catch(() => null);
117
+ doc = own?.id === doc.id ? own : null;
118
+ }
119
+ if (!doc?.id) {
120
+ return json(res, 400, { error: `could not fetch the old account (${input}) — it must exist and answer` });
121
+ }
122
+ if (doc.id === urls.actor) return json(res, 400, { error: 'that is this account' });
123
+ if (!aliases.includes(doc.id)) aliases.push(doc.id);
124
+ } else {
125
+ const target = String(body.remove).trim();
126
+ if (!aliases.includes(target)) return json(res, 404, { error: 'no such alias' });
127
+ // Follower servers process a Move on their own retry schedules,
128
+ // over days — an alias removed early strands the late ones.
129
+ if (!body.confirm) {
130
+ return json(res, 409, {
131
+ error: 'servers still retrying the Move check this alias and would strand '
132
+ + 'their followers — send confirm: true to remove it anyway',
133
+ });
134
+ }
135
+ aliases.splice(aliases.indexOf(target), 1);
136
+ }
137
+ cfg.aliases = aliases;
138
+ agent.store.setConfig(cfg);
139
+ agent.publisher.config.aliases = aliases;
140
+ await agent.store.flush();
141
+ const pub = await agent.publisher.publishProfile();
142
+ return json(res, 200, {
143
+ ok: true, aliases,
144
+ ...(pub?.unreachable?.length ? { unreachable: pub.unreachable } : {}),
145
+ });
146
+ }
147
+ // Stage a CSV export from the old account. Rows are applied by the
148
+ // agent's paced worker, not in this request — big lists take a while,
149
+ // and GET /import is the window onto how far it has gotten.
150
+ case '/import': {
151
+ if (!agent.importer) return json(res, 409, { error: 'agent not connected yet' });
152
+ // Unlike the one-shot routes, staging arms a worker that keeps
153
+ // writing for minutes — a device that could not take the lease must
154
+ // not run one alongside the device that holds it.
155
+ const took = await agent.requestTakeover?.();
156
+ if (took === false) {
157
+ return json(res, 503, { error: 'another device is active for this pod — import from there' });
158
+ }
159
+ if (body.clear === true) {
160
+ agent.importer.clear();
161
+ return json(res, 200, { ok: true, cleared: true });
162
+ }
163
+ if (!IMPORT_KINDS.includes(body.kind)) {
164
+ return json(res, 400, { error: `kind must be one of: ${IMPORT_KINDS.join(', ')}` });
165
+ }
166
+ if (typeof body.text !== 'string' || !body.text.trim()) {
167
+ return json(res, 400, { error: 'text required — the CSV file contents' });
168
+ }
169
+ const { values, invalid } = normalizeImport(body.kind, body.text);
170
+ const r = agent.importer.stage(body.kind, values);
171
+ await agent.store.flush();
172
+ return json(res, 200, {
173
+ ok: true, kind: body.kind, ...r,
174
+ invalid: invalid.length,
175
+ ...(invalid.length ? { invalidSample: invalid.slice(0, 5) } : {}),
176
+ });
177
+ }
178
+ case '/retire': {
179
+ // Irreversible and federated: a Delete leaves for every follower and
180
+ // the actor becomes a Tombstone. The typed handle is the interlock —
181
+ // a stray click cannot produce it, and nor can anything that never
182
+ // read the record.
183
+ if (!body.confirm || body.confirm !== agent.store.getConfig()?.handle) {
184
+ return json(res, 400, { error: 'type the handle to confirm' });
185
+ }
186
+ await agent.requestTakeover?.();
187
+ return json(res, 200, { ok: true, ...await agent.publisher.retireActor() });
188
+ }
189
+ default: return false;
190
+ }
191
+ }