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,393 @@
1
+ // setup.mjs — the first run and the process: setup driven from the page,
2
+ // a new actor in a new home, starting a stopped one, updating and
3
+ // restarting, moving the private half, and stopping this process.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+ import { spawn } from 'node:child_process';
9
+ import { pathToFileURL } from 'node:url';
10
+ import { isCrossSiteNavigation } from '../../../shared/guard.mjs';
11
+ import { identityHomes, rootOf, tildify, writeJsonAtomic } from '../../home.mjs';
12
+ import { copyPrivateHalf, isCurrent, CURRENT_LAYOUT } from '../../migrate.mjs';
13
+ import { insecureUrlReason } from '../../../shared/safefetch.mjs';
14
+ import { newRun, preflight, runSetup, setupInputError, hasCredential, credentialPath } from '../../setup.mjs';
15
+ import { portFree, freePortFrom } from '../../ports.mjs';
16
+ import { yieldDirectory } from '../../../gateway/directory.mjs';
17
+ import { localFetch } from '../../../client/localapi.mjs';
18
+ import { projectRoot, SETUP_PAGE } from '../static.mjs';
19
+ import { secureOrigin } from '../origins.mjs';
20
+
21
+ // The signup carry-over: gateway and identity parameters a signup page
22
+ // recorded for this install (written by the installer, consumed by the first
23
+ // setup run). Read fresh each time — the file is deleted once consumed.
24
+ function readFirstRun() {
25
+ const file = process.env.AP_FIRST_RUN;
26
+ if (!file) return null;
27
+ try {
28
+ const fr = JSON.parse(fs.readFileSync(file, 'utf8'));
29
+ return fr && typeof fr === 'object' ? fr : null;
30
+ } catch { return null; }
31
+ }
32
+
33
+ // The same config shape the `gateway` CLI command writes.
34
+ function gatewayConfigFrom(fr) {
35
+ if (!fr?.gateway || !fr?.secret) return null;
36
+ const target = String(fr.gateway);
37
+ return fr.fronted
38
+ ? { url: target.replace(/ap\/actor$/, 'ap/inbox/'), frontActor: target,
39
+ mode: 'shadow', hmacSecret: String(fr.secret) }
40
+ : { url: target.replace(/\/?$/, '/'), mode: 'shadow', hmacSecret: String(fr.secret) };
41
+ }
42
+
43
+ export async function get(p, ctx, req, res, url) { // eslint-disable-line no-unused-vars
44
+ const { agent, log, allowed, embedded, port, publicOrigin, versionOnDisk, isGroup } = ctx; // eslint-disable-line no-unused-vars
45
+ const json = (res, status, obj) => { ctx.json(res, status, obj); return true; };
46
+
47
+ // ---- setup and configuration, for the pages under /admin/ ----
48
+ // These answer for a group too: a group is set up in the browser like
49
+ // anything else, and it has a display name to change.
50
+ if (req.method === 'GET' && p === '/setup/state') {
51
+ const home = agent.home || null;
52
+ const held = home && hasCredential(home) ? agent.readCredential?.() : null;
53
+ return json(res, 200, {
54
+ hasCredential: !!(home && hasCredential(home)),
55
+ configured: agent.configured(),
56
+ // Not the same question: a crashed setup leaves a credential with no
57
+ // actor behind it, and that is finishable without minting a second.
58
+ resumable: !!(home && hasCredential(home)) && !agent.configured(),
59
+ running: ctx.setup.run?.phase === 'running',
60
+ phase: ctx.setup.run?.phase || 'idle',
61
+ home,
62
+ port,
63
+ handle: agent.store.getConfig()?.handle || allowed.label || null,
64
+ kind: agent.store.getConfig()?.kind || null,
65
+ origins: {
66
+ loopback: publicOrigin
67
+ || `https://localhost:${port}/`,
68
+ named: publicOrigin
69
+ ? null
70
+ : allowed.label
71
+ ? (true
72
+ ? `https://${allowed.label}.localhost:${port}/`
73
+ : `http://${allowed.label}.localhost:${port}/`)
74
+ : null,
75
+ },
76
+ // Set in the environment, the password never needs to reach the page.
77
+ passwordSupplied: !!process.env.AP_PASSWORD,
78
+ identity: held ? { pod: held.remotePod, issuer: held.issuerOrigin, root: held.root || null } : null,
79
+ defaults: { issuer: 'https://solidcommunity.net', port: 8030 },
80
+ // What a signup page arranged for this install, so the form opens
81
+ // pre-filled. The receipt secret stays out of it — the /setup run
82
+ // reads that from the file itself.
83
+ firstRun: (() => {
84
+ if (agent.configured()) return null;
85
+ const fr = readFirstRun();
86
+ if (!fr?.handle) return null;
87
+ let gatewayHost = null;
88
+ try { gatewayHost = new URL(fr.gateway).host; } catch { /* no gateway named */ }
89
+ return { pod: fr.pod || null, issuer: fr.issuer || null, handle: String(fr.handle),
90
+ kind: fr.kind === 'group' ? 'group' : 'person', fronted: !!fr.fronted, gatewayHost };
91
+ })(),
92
+ });
93
+ }
94
+ if (req.method === 'GET' && p === '/setup/progress') {
95
+ return json(res, 200, ctx.setup.run || { phase: 'idle', steps: [], error: null, result: null });
96
+ }
97
+
98
+ return false;
99
+ }
100
+
101
+ export async function post(p, body, ctx, req, res) { // eslint-disable-line no-unused-vars
102
+ const { agent, log, allowed, embedded, port, publicOrigin, versionOnDisk, isGroup } = ctx; // eslint-disable-line no-unused-vars
103
+ const json = (res, status, obj) => { ctx.json(res, status, obj); return true; };
104
+ switch (p) {
105
+ // Stop an agent whose pidfile is gone and which no terminal owns
106
+ // (backgrounded, orphaned by a closed shell). It used to sit ABOVE both
107
+ // the configured() gate and the isLocal one, so it was the only
108
+ // state-changing route that needed neither — reachable from any host
109
+ // AP_ALLOWED_HOSTS named, on an agent that had never been set up.
110
+ case '/shutdown': {
111
+ json(res, 200, { ok: true, stopping: process.pid });
112
+ setTimeout(() => process.kill(process.pid, 'SIGTERM'), 50);
113
+ return true;
114
+ }
115
+ // ---- setup, driven by the page at /admin/setup/ ----
116
+ case '/setup/check': return json(res, 200, preflight(body));
117
+ // Discard a credential that never finished setup, so the account and
118
+ // pod can be entered again. The credential a CSS server mints is shown
119
+ // once, so a setup that stops after the mint (a wrong pod answers 401
120
+ // to the first write) leaves the form in "finish" mode with no way to
121
+ // re-enter what was wrong. This removes it locally and reopens the full
122
+ // form. It does NOT revoke server-side — that needs the account
123
+ // password (`fedipod revoke-credential`); the old credential is left on
124
+ // the account, revocable from its dashboard.
125
+ case '/setup/reset': {
126
+ if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
127
+ // A working identity is never swapped out this way — that is a
128
+ // teardown (`fedipod retire`), not a half-finished setup.
129
+ if (agent.configured()) {
130
+ return json(res, 409, { error: 'this home holds a working identity — retire it, do not reset' });
131
+ }
132
+ if (ctx.setup.run?.phase === 'running') {
133
+ return json(res, 409, { error: 'setup is running — let it finish or stop it first', phase: 'running' });
134
+ }
135
+ const home = agent.home;
136
+ if (!home) return json(res, 500, { error: 'this agent has no AP_HOME to reset' });
137
+ const removed = hasCredential(home);
138
+ if (removed) fs.rmSync(credentialPath(home), { force: true });
139
+ // Drop the pod handle too, so configured() cannot flicker true off a
140
+ // stale in-memory session while the fresh form is filled in.
141
+ agent.remote = null;
142
+ ctx.setup.run = null;
143
+ return json(res, 200, { ok: true, removed });
144
+ }
145
+ case '/setup': {
146
+ // A visited page must not be able to navigate this into existence.
147
+ if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
148
+ if (agent.configured()) {
149
+ return json(res, 409, {
150
+ error: 'this home already holds an identity',
151
+ pod: agent.store.getConfig()?.remotePod || null,
152
+ });
153
+ }
154
+ if (ctx.setup.run?.phase === 'running') {
155
+ return json(res, 409, { error: 'setup is already running', phase: 'running' });
156
+ }
157
+ const home = agent.home;
158
+ if (!home) return json(res, 500, { error: 'this agent has no AP_HOME to set up' });
159
+ // A crashed setup left a credential with no actor behind it. That is
160
+ // finishable, and must NOT mint a second credential — the first is
161
+ // unrecoverable and would be orphaned.
162
+ const resuming = hasCredential(home);
163
+ const answers = { ...body, password: body.password || process.env.AP_PASSWORD || '' };
164
+ // The signup carry-over: attach to the gateway the signup arranged,
165
+ // set before the first publish so the actor never advertises the pod
166
+ // inbox first. The secret comes from the installer's file, never the
167
+ // page.
168
+ const gatewayCfg = gatewayConfigFrom(readFirstRun());
169
+ if (gatewayCfg) answers.gateway = gatewayCfg;
170
+ const bad = setupInputError(answers, resuming);
171
+ if (bad) return json(res, 400, { error: bad });
172
+ if (!resuming) {
173
+ const pre = preflight(answers);
174
+ if (!pre.ok) return json(res, 400, { ...pre, error: pre.error || pre.refusal });
175
+ }
176
+ // Widen the allowlist now, not at the end: the page offers to move to
177
+ // the named origin, which has to answer before it is offered.
178
+ allowed.setHandle(answers.handle);
179
+ ctx.setup.run = newRun();
180
+ // Deliberately not awaited. The credential a CSS server mints is
181
+ // shown once, so what a run depends on is the file it writes, never
182
+ // a connection a closed tab can take with it.
183
+ runSetup({ home, agent, answers, run: ctx.setup.run, log })
184
+ .then((r) => {
185
+ // The carried secret is consumed; a finished setup deletes it.
186
+ if (r?.phase === 'done' && process.env.AP_FIRST_RUN) {
187
+ try { fs.unlinkSync(process.env.AP_FIRST_RUN); } catch { /* already gone */ }
188
+ }
189
+ })
190
+ .catch(e => { ctx.setup.run.phase = 'error'; ctx.setup.run.error = e.message; });
191
+ return json(res, 202, { ok: true, running: true, resuming });
192
+ }
193
+ // One identity per home, so a new actor means a new home, a free port
194
+ // and a process there — then its own setup, run with the answers this
195
+ // page collected. The reply is the address of the new actor's page,
196
+ // which is where the progress of that setup is reported.
197
+ case '/new-actor': {
198
+ if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
199
+ const handle = String(body.handle || '').trim().toLowerCase();
200
+ // The name first, and before anything is created: it is what becomes
201
+ // a directory, and "mode must be new or existing" is a poor answer to
202
+ // a handle that could climb out of profiles/.
203
+ // Named after the handle rather than generated, so `profiles` and
204
+ // `--profile <name>` stay legible a year from now.
205
+ if (!/^[a-z0-9][a-z0-9_-]{0,30}$/.test(handle)) {
206
+ return json(res, 400, { error: 'a handle is letters, digits, hyphens and underscores' });
207
+ }
208
+ const dir = path.join(rootOf(path.resolve(agent.home || '')), 'profiles', handle);
209
+ if (fs.existsSync(path.join(dir, 'credential.json'))) {
210
+ return json(res, 409, { error: `${handle} already exists — it is in the list above` });
211
+ }
212
+ // Then everything else setup needs, so a missing password is refused
213
+ // here rather than after a process has been started for it.
214
+ const bad = setupInputError({ ...body, handle });
215
+ if (bad) return json(res, 400, { error: bad });
216
+ const newPort = await freePortFrom(port + 1);
217
+ if (!newPort) return json(res, 503, { error: 'no free port in the next 50' });
218
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
219
+ const child = spawn(process.execPath, [path.join(projectRoot, 'run-agent.mjs')], {
220
+ detached: true, stdio: 'ignore',
221
+ env: { ...process.env, AP_HOME: dir, AP_PORT: String(newPort), AP_PROFILE: '' },
222
+ });
223
+ child.unref();
224
+ // Answer only once it is listening: handing back a URL that is not
225
+ // up yet shows a connection error instead of the setup form.
226
+ let listening = false;
227
+ for (let i = 0; i < 60 && !listening; i++) {
228
+ await new Promise(r => setTimeout(r, 250));
229
+ listening = await localFetch(agent.home, newPort, '/status', { timeout: 1000 })
230
+ .then(r => r.ok).catch(() => false);
231
+ }
232
+ if (!listening) return json(res, 504, { error: `started it on ${newPort} but it never answered` });
233
+
234
+ // Its own /setup owns the work — minting the credential, provisioning
235
+ // the pod, publishing. It answers 202 and runs in the background, so
236
+ // the page that opens next is the one reporting progress.
237
+ const started = await localFetch(agent.home, newPort, '/setup', {
238
+ method: 'POST',
239
+ headers: { 'content-type': 'application/json' },
240
+ body: JSON.stringify({ ...body, handle }),
241
+ }).then(async r => ({ status: r.status, json: await r.json().catch(() => null) }))
242
+ .catch(e => ({ status: 0, json: { error: e.message } }));
243
+ if (started.status !== 202) {
244
+ return json(res, started.status || 502, {
245
+ error: started.json?.error || `the new agent refused setup (HTTP ${started.status})`,
246
+ port: newPort, url: `${secureOrigin(handle, newPort)}${SETUP_PAGE}`,
247
+ });
248
+ }
249
+ return json(res, 200,
250
+ { ok: true, handle, port: newPort,
251
+ url: `${secureOrigin(handle, newPort)}${SETUP_PAGE}` });
252
+ }
253
+ // Start an actor that is not running, so its page can be visited. The
254
+ // same spawn as /new-actor, minus the making: this one has a home
255
+ // already. Its recorded port is preferred so its address stays what it
256
+ // was, but a port something else has taken is walked past, not fought.
257
+ case '/start-actor': {
258
+ if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
259
+ const name = String(body.name || '');
260
+ const found = identityHomes(rootOf(path.resolve(agent.home || ''))).find(h => h.name === name);
261
+ if (!found) return json(res, 404, { error: `no identity called ${name}` });
262
+
263
+ let want = null;
264
+ try { want = JSON.parse(fs.readFileSync(path.join(found.dir, 'agent.json'), 'utf8')).port || null; } catch {}
265
+ if (want) {
266
+ // Its own handle, not the profile directory's name: usually the
267
+ // same, but a renamed profile makes them differ and the origin has
268
+ // to be one that agent's own guard will accept.
269
+ const live = await localFetch(agent.home, want, '/status', { timeout: 1000 })
270
+ .then(r => (r.ok ? r.json() : null)).catch(() => null);
271
+ if (live) {
272
+ return json(res, 200, {
273
+ ok: true, name, port: want, already: true,
274
+ url: `${secureOrigin(live.handle || name, want)}/`,
275
+ });
276
+ }
277
+ }
278
+ // The port is not the only evidence it is running. An agent from
279
+ // before agent.json was written at startup has a live pidfile and no
280
+ // recorded port, and spawning past that gives one home two agents —
281
+ // which is exactly how this machine ended up with two on profiles/jeff.
282
+ let held = null;
283
+ try { held = Number(fs.readFileSync(path.join(found.dir, 'agent.pid'), 'utf8').trim()) || null; } catch {}
284
+ if (held) {
285
+ let alive = false;
286
+ try { process.kill(held, 0); alive = true; } catch { /* gone */ }
287
+ if (alive) {
288
+ return json(res, 409, {
289
+ error: `${name} is already running as pid ${held}, on a port it never recorded. `
290
+ + `Stop it first: kill ${held}`,
291
+ });
292
+ }
293
+ }
294
+ // A recorded port held by the directory door is still this
295
+ // identity's port — the door steps aside for its owner.
296
+ if (want && !await portFree(want)) await yieldDirectory(want, { portFree });
297
+ const on = (want && await portFree(want)) ? want : await freePortFrom(port + 1);
298
+ if (!on) return json(res, 503, { error: 'no free port in the next 50' });
299
+
300
+ const started = spawn(process.execPath, [path.join(projectRoot, 'run-agent.mjs')], {
301
+ detached: true, stdio: 'ignore',
302
+ env: { ...process.env, AP_HOME: found.dir, AP_PORT: String(on), AP_PROFILE: '' },
303
+ });
304
+ started.unref();
305
+ for (let i = 0; i < 60; i++) {
306
+ await new Promise(r => setTimeout(r, 250));
307
+ const up = await localFetch(agent.home, on, '/status', { timeout: 1000 })
308
+ .then(r => (r.ok ? r.json() : null)).catch(() => null);
309
+ if (up) {
310
+ return json(res, 200, { ok: true, name, port: on,
311
+ url: `${secureOrigin(up.handle || name, on)}/` });
312
+ }
313
+ }
314
+ return json(res, 504, { error: `started ${name} on ${on} but it never answered` });
315
+ }
316
+ // Pull the latest published FediPod and restart the agents. What the
317
+ // installer does on a re-run, offered where the version line is.
318
+ case '/update': {
319
+ if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
320
+ const { runUpdate, restartAgents, repoRoot } = await import('../../update.mjs');
321
+ const r = runUpdate({ log });
322
+ if (!r.ok) return json(res, 409, { error: r.note });
323
+ json(res, 200, { ok: true, note: r.note, restarting: true });
324
+ // The response first, the restart a beat later — a self-restart must
325
+ // not take the answer down with it.
326
+ setTimeout(() => {
327
+ if (restartAgents({ log }) === 'self') {
328
+ const child = spawn(process.execPath, [path.join(repoRoot(), 'run-agent.mjs')],
329
+ { detached: true, stdio: 'ignore', env: process.env });
330
+ child.unref();
331
+ setTimeout(() => process.exit(0), 300);
332
+ }
333
+ }, 200);
334
+ return true;
335
+ }
336
+ // Move the private half — every state document and the RDF tree — while
337
+ // the agent runs: quiesce this process's writers, flush, copy, verify,
338
+ // repoint the credential, then reconnect on the new location. The old
339
+ // copy is left where it was, exactly as the CLI move leaves it.
340
+ case '/state-move': {
341
+ const cred = agent.readCredential?.();
342
+ if (!cred) return json(res, 409, { error: 'no credential — run setup first' });
343
+ const to = String(body.to || '').trim();
344
+ if (!to) return json(res, 400, { error: 'say where it should go' });
345
+ let target = null;
346
+ if (to !== 'pod') {
347
+ // A path or a URL — two chars before the colon, so a Windows drive
348
+ // letter reads as a path rather than a scheme.
349
+ const asPath = !/^[a-z][a-z0-9+.-]+:/i.test(to);
350
+ const raw = asPath
351
+ ? pathToFileURL(path.resolve(to.replace(/^~(?=[/\\]|$)/, os.homedir()))).href
352
+ : to;
353
+ target = raw.endsWith('/') ? raw : raw + '/';
354
+ try { new URL(target); } catch { return json(res, 400, { error: `"${to}" is not a container URL or a path` }); }
355
+ if (/^https?:/i.test(target)) {
356
+ const bad = insecureUrlReason(target, 'private-data address');
357
+ if (bad) return json(res, 400, { error: bad });
358
+ }
359
+ }
360
+ const whereState = (c) => (c.privateRoot ? tildify(c.privateRoot) : 'on the pod');
361
+ if ((cred.privateRoot || null) === target) {
362
+ return json(res, 200, { ok: true, docs: 0, notes: 0, unchanged: true, now: whereState(cred) });
363
+ }
364
+ await agent.requestTakeover?.();
365
+ agent.intake?.stop(); agent.deliverer?.stop(); agent.tagfeed?.stop(); agent.importer?.stop();
366
+ try {
367
+ await agent.store.flush();
368
+ const destCred = { ...cred, privateRoot: target };
369
+ const copied = await copyPrivateHalf({
370
+ from: { state: agent.privateStorage(cred, 'state') },
371
+ to: { state: agent.privateStorage(destCred, 'state') },
372
+ log,
373
+ });
374
+ if (target) cred.privateRoot = target; else delete cred.privateRoot;
375
+ if (isCurrent(cred)) cred.layout = CURRENT_LAYOUT; else delete cred.layout;
376
+ writeJsonAtomic(path.join(agent.home, 'credential.json'), cred);
377
+ // connect() rebuilds everything that pointed at the old location —
378
+ // store, publisher, intake — and restarts them.
379
+ agent.store.attach(agent.privateStorage(cred, 'state'));
380
+ agent.stateLoaded = false;
381
+ await agent.connect();
382
+ return json(res, 200, { ok: true, ...copied, now: whereState(cred) });
383
+ } catch (e) {
384
+ // The credential is only repointed after a verified copy, so on any
385
+ // failure reconnect puts the agent back to work where it was.
386
+ agent.stateLoaded = false;
387
+ await agent.connect().catch(() => {});
388
+ return json(res, 502, { error: e.message });
389
+ }
390
+ }
391
+ default: return false;
392
+ }
393
+ }
@@ -0,0 +1,188 @@
1
+ // social.mjs — the owner acting on the social graph, and a group operator
2
+ // moderating: posting, the tag feed, follows, mutes, blocks, ejecting,
3
+ // retracting, review, joins, the moderation queue.
4
+
5
+ import { followHandle, followActor, unfollowActor, ejectFollower, retractAnnouncement, admitRequest, refuseRequest, applyModeration } from '../../../core/social.mjs';
6
+
7
+ export async function get(p, ctx, req, res, url) { // eslint-disable-line no-unused-vars
8
+ const { agent, log, allowed, embedded, port, publicOrigin, versionOnDisk, isGroup } = ctx; // eslint-disable-line no-unused-vars
9
+ const json = (res, status, obj) => { ctx.json(res, status, obj); return true; };
10
+
11
+ if (req.method === 'GET' && p === '/tagfeed') {
12
+ return json(res, 200, agent.tagfeed
13
+ ? { ...agent.tagfeed.config(), lastSweep: agent.tagfeed.lastSweep, lastAdded: agent.tagfeed.lastAdded }
14
+ : { error: 'agent not configured' });
15
+ }
16
+
17
+ return false;
18
+ }
19
+
20
+ export async function post(p, body, ctx, req, res) { // eslint-disable-line no-unused-vars
21
+ const { agent, log, allowed, embedded, port, publicOrigin, versionOnDisk, isGroup } = ctx; // eslint-disable-line no-unused-vars
22
+ const json = (res, status, obj) => { ctx.json(res, status, obj); return true; };
23
+ switch (p) {
24
+ case '/post': {
25
+ if (!body.content) return json(res, 400, { error: 'content required' });
26
+ const note = await agent.publisher.publishNote(body.content, { inReplyTo: body.inReplyTo });
27
+ return json(res, 200, { ok: true, id: note.id });
28
+ }
29
+ case '/tagfeed': return json(res, 200, agent.tagfeed.setConfig(body));
30
+ case '/archive': {
31
+ // Whether drained mail's original bytes are kept in the private
32
+ // half's inbox-archive/. Absent means on.
33
+ const cfg = agent.store.getConfig();
34
+ agent.store.setConfig({ ...cfg, archiveInbox: !!body.on });
35
+ await agent.store.flush();
36
+ return json(res, 200, { ok: true, archiveInbox: !!body.on });
37
+ }
38
+ case '/follow': {
39
+ // By handle normally; by actor URL when there is no handle to resolve —
40
+ // a pod on a path, or anything WebFinger cannot answer for. Pasting an
41
+ // actor URL is a normal way to follow in Mastodon too.
42
+ if (body.actor) { await followActor(agent, body.actor); return json(res, 200, { ok: true, actor: body.actor }); }
43
+ if (!body.handle) return json(res, 400, { error: 'handle or actor required' });
44
+ return json(res, 200, await followHandle(agent, body.handle));
45
+ }
46
+ case '/unfollow': return json(res, 200, await unfollowActor(agent, body.actor));
47
+ case '/mute':
48
+ case '/unmute': {
49
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
50
+ if (!body.actor) return json(res, 400, { error: 'actor required' });
51
+ const m = agent.store.getMuted();
52
+ m.actors = p === '/mute'
53
+ ? [...new Set([...m.actors, body.actor])]
54
+ : m.actors.filter(a => a !== body.actor);
55
+ agent.store.setMuted(m);
56
+ await agent.store.flush();
57
+ return json(res, 200, { ok: true, actors: m.actors });
58
+ }
59
+ case '/eject': {
60
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
61
+ if (!body.actor) return json(res, 400, { error: 'actor required' });
62
+ const r = await ejectFollower(agent, body.actor);
63
+ await agent.store.flush();
64
+ return json(res, 200, r);
65
+ }
66
+ case '/retract': {
67
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
68
+ if (!body.noteId) return json(res, 400, { error: 'noteId required' });
69
+ const r = await retractAnnouncement(agent, body.noteId);
70
+ await agent.store.flush();
71
+ return json(res, 200, r);
72
+ }
73
+ case '/review': {
74
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
75
+ agent.store.setConfig({ ...agent.store.getConfig(), review: !!body.on });
76
+ await agent.store.flush();
77
+ return json(res, 200, { ok: true, review: !!body.on });
78
+ }
79
+ case '/describe': {
80
+ const cfg = { ...agent.store.getConfig() };
81
+ if ('summary' in body) cfg.summary = body.summary || undefined;
82
+ if ('icon' in body) cfg.icon = body.icon || undefined;
83
+ agent.store.setConfig(cfg);
84
+ Object.assign(agent.publisher.config, { summary: cfg.summary, icon: cfg.icon });
85
+ await agent.store.flush();
86
+ await agent.publisher.publishProfile(); // the bio and avatar are on the wire
87
+ return json(res, 200, { ok: true, summary: cfg.summary || null, icon: cfg.icon || null });
88
+ }
89
+ case '/joins': {
90
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
91
+ const cfg = { ...agent.store.getConfig(), approveJoins: !!body.approve };
92
+ agent.store.setConfig(cfg);
93
+ agent.publisher.config.approveJoins = !!body.approve;
94
+ await agent.store.flush();
95
+ // Unlike review, this one is visible to the fediverse: the actor
96
+ // document carries manuallyApprovesFollowers, so nothing changes for
97
+ // anyone until it is republished.
98
+ await agent.publisher.publishProfile();
99
+ return json(res, 200, { ok: true, approveJoins: !!body.approve });
100
+ }
101
+ case '/admit':
102
+ case '/refuse': {
103
+ // Kind-agnostic, like the queue they act on: admitting adds a
104
+ // follower and Accepts, refusing Rejects. A person needs both.
105
+ // `all` answers the whole queue — the shape a migration wave arrives
106
+ // in — with one collections republish for the lot.
107
+ if (p === '/admit' && body.all === true) {
108
+ const waiting = agent.store.getRequests().map(r => r.actor);
109
+ let admitted = 0;
110
+ for (const actor of waiting) {
111
+ try { await admitRequest(agent, actor, { publish: false }); admitted++; }
112
+ catch (e) { log(`admit ${actor} failed: ${e.message}`); }
113
+ }
114
+ if (admitted) await agent.publisher.publishCollections({ followers: true, pending: true });
115
+ await agent.store.flush();
116
+ return json(res, 200, { ok: true, admitted, requests: agent.store.getRequests().length });
117
+ }
118
+ if (!body.actor) return json(res, 400, { error: 'actor required' });
119
+ const r = p === '/admit' ? await admitRequest(agent, body.actor)
120
+ : await refuseRequest(agent, body.actor);
121
+ await agent.store.flush();
122
+ return json(res, 200, { ...r, requests: agent.store.getRequests().length });
123
+ }
124
+ case '/approve':
125
+ case '/decline': {
126
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
127
+ if (!body.noteId) return json(res, 400, { error: 'noteId required' });
128
+ const held = agent.store.getPending().some(x => x.noteId === body.noteId);
129
+ if (!held) return json(res, 404, { error: 'not held for review' });
130
+ if (p === '/approve') await agent.intake.amplify(body.noteId, { approved: true });
131
+ else agent.store.setPending(agent.store.getPending().filter(x => x.noteId !== body.noteId));
132
+ await agent.store.flush();
133
+ return json(res, 200, { ok: true, noteId: body.noteId, pending: agent.store.getPending().length });
134
+ }
135
+ // Answer one queued moderation ask: apply it (the operator vouching
136
+ // for a delivery nothing else can vouch for) or dismiss it.
137
+ case '/modqueue': {
138
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
139
+ const q = agent.store.read('modqueue.json', []);
140
+ const entry = q.find(e => e.id === body.id);
141
+ if (!entry) return json(res, 404, { error: 'no such queued action' });
142
+ if (body.action !== 'apply' && body.action !== 'dismiss') {
143
+ return json(res, 400, { error: 'action must be apply or dismiss' });
144
+ }
145
+ let applied = null;
146
+ if (body.action === 'apply') applied = await applyModeration(agent, entry);
147
+ agent.store.write('modqueue.json', q.filter(e => e.id !== entry.id));
148
+ await agent.store.flush();
149
+ return json(res, 200, {
150
+ ok: true, action: body.action, type: entry.type, moderator: entry.moderator,
151
+ ...(applied && typeof applied === 'object' ? { result: applied } : {}),
152
+ remaining: agent.store.read('modqueue.json', []).length,
153
+ });
154
+ }
155
+ // Symmetrical with /block, and open for the same reason: a block made
156
+ // by mistake is worth undoing before federation is even configured.
157
+ case '/unblock': {
158
+ const b = agent.store.getBlocklist();
159
+ const before = b.domains.length + b.actors.length;
160
+ if (body.actor) b.actors = b.actors.filter(a => a !== body.actor);
161
+ else if (body.domain) b.domains = b.domains.filter(d => d !== body.domain);
162
+ else return json(res, 400, { error: 'domain or actor required' });
163
+ agent.store.setBlocklist(b);
164
+ // The published blocked collection (FEP-c648) follows, best-effort.
165
+ Promise.resolve(agent.publisher?.publishCollections?.({ blocked: true })).catch(() => {});
166
+ return json(res, 200, {
167
+ ok: true, removed: before - (b.domains.length + b.actors.length),
168
+ domains: b.domains, actors: b.actors,
169
+ });
170
+ }
171
+ case '/block': {
172
+ const b = agent.store.getBlocklist();
173
+ if (body.actor) {
174
+ if (!/^https?:\/\/\S+$/.test(body.actor)) return json(res, 400, { error: 'actor must be a URL' });
175
+ if (!b.actors.includes(body.actor)) b.actors.push(body.actor);
176
+ } else if (body.domain) {
177
+ if (!b.domains.includes(body.domain)) b.domains.push(body.domain);
178
+ } else {
179
+ return json(res, 400, { error: 'domain or actor required' });
180
+ }
181
+ agent.store.setBlocklist(b);
182
+ // The published blocked collection (FEP-c648) follows, best-effort.
183
+ Promise.resolve(agent.publisher?.publishCollections?.({ blocked: true })).catch(() => {});
184
+ return json(res, 200, { ok: true, domains: b.domains, actors: b.actors });
185
+ }
186
+ default: return false;
187
+ }
188
+ }