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,95 @@
1
+ // server.mjs — the DeviceAgent's listener: the https servers on both
2
+ // loopbacks, the pidfile, the directory door, and the retry when the port is
3
+ // held by that door.
4
+
5
+ import https from 'node:https';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { createRequire } from 'node:module';
10
+ import { Authorities } from '../../shared/guard.mjs';
11
+ import { rootOf } from '../home.mjs';
12
+ import { portFree } from '../ports.mjs';
13
+ import { claimDirectory, yieldDirectory } from '../../gateway/directory.mjs';
14
+ import { ensureTrustedTls } from '../certs.mjs';
15
+ import { localVersion } from '../update.mjs';
16
+ import { projectRoot } from './static.mjs';
17
+ import { secureOrigin } from './origins.mjs';
18
+ import { buildAdminSurface } from './surface.mjs';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const { makeGate } = require(path.join(projectRoot, 'vendor/gate.cjs'));
22
+
23
+ export function startAdmin({ port, gateToken, agent, log = console.log, handle = null, tls = null,
24
+ // Injectable, so a test can put a checkout ahead of the running process
25
+ // without editing the package.json of the machine running the test.
26
+ versionOnDisk = () => localVersion(projectRoot) }) {
27
+ const gate = makeGate(gateToken);
28
+ // Live, so the named origin appears the moment connect() reads the handle
29
+ // out of pod state — including for the OAuth redirect check in MastoApi.
30
+ // The https listener's port joins the authority set: same names, second port.
31
+ const allowed = new Authorities(port, handle);
32
+ agent.authorities = allowed;
33
+ const { handler, streaming } = buildAdminSurface({ agent, gate, allowed, log, port, handle, versionOnDisk });
34
+
35
+ // Loopback both ways: the canonical URL is https://localhost:<port>/, and
36
+ // "localhost" resolves to ::1 on many systems before falling back to IPv4 —
37
+ // answer on both so the same origin always works (one origin = one
38
+ // browser storage = one login). There is one listener and it is https: the
39
+ // port you name is the port you browse.
40
+ // Every listener here is https, so a caller that did not bring a certificate
41
+ // gets this install's own rather than an exception: throwing here killed the
42
+ // signal handlers registered after the call, and the agent became unstoppable.
43
+ if (!tls) {
44
+ tls = ensureTrustedTls(path.join(rootOf(agent.home || os.tmpdir()), 'certs'),
45
+ { log, names: allowed.label ? [`${allowed.label}.localhost`] : [] });
46
+ }
47
+ const tlsOpts = { key: tls.key, cert: tls.cert };
48
+ const server = https.createServer(tlsOpts, handler);
49
+ streaming.attach(server);
50
+ const server6 = https.createServer(tlsOpts, handler);
51
+ streaming.attach(server6);
52
+ server6.on('error', () => { /* no IPv6 loopback on this system — IPv4 covers it */ });
53
+ const onListen = () => {
54
+ // Pidfile for `fedipod stop` — written only AFTER the listen
55
+ // succeeds, so a port-race loser can never clobber the live agent's pid.
56
+ try {
57
+ if (agent.home) fs.writeFileSync(path.join(agent.home, 'agent.pid'), String(process.pid) + '\n');
58
+ } catch { /* stop will report no pidfile */ }
59
+ // The named origin when there is one: on a detached start this line is the
60
+ // only record of where to browse, and sending you to the shared origin is
61
+ // how two identities end up in one browser storage bucket.
62
+ log(`FediPod on ${secureOrigin(allowed.label, port)}/ (UI + API)`
63
+ + (tls.trust ? '' : ' (self-signed — your client may ask once to trust it)'));
64
+ // Hold the well-known door: whoever answers on the directory port sends the
65
+ // browser to its own record page, which lists every identity on this
66
+ // machine. Only a configured agent qualifies — an unconfigured one's record
67
+ // page is a setup form with no identity list.
68
+ const door = claimDirectory({ port,
69
+ origin: () => secureOrigin(allowed.label, port),
70
+ log, eligible: () => agent.configured(), tls });
71
+ // Closing the IPv4 listener has to take the IPv6 one and the door with it,
72
+ // or a shutdown leaves a listening handle and the process never exits.
73
+ server.on('close', () => { door.stop(); try { server6.close(); } catch { /* already down */ } });
74
+ };
75
+ let retriedDoor = false;
76
+ server.on('error', async (e) => {
77
+ if (e.code === 'EADDRINUSE') {
78
+ // The directory door yields to a real owner of its port; anything else
79
+ // holding it is a genuine conflict.
80
+ if (!retriedDoor && await yieldDirectory(port, { portFree, home: agent.home })) {
81
+ retriedDoor = true;
82
+ server.listen(port, '127.0.0.1', onListen);
83
+ if (!server6.listening) server6.listen(port, '::1');
84
+ return;
85
+ }
86
+ log(`port ${port} is already in use by another server — set AP_PORT to a free port and retry`);
87
+ process.exit(1);
88
+ }
89
+ throw e;
90
+ });
91
+ server.listen(port, '127.0.0.1', onListen);
92
+ server6.listen(port, '::1');
93
+
94
+ return server;
95
+ }
@@ -0,0 +1,244 @@
1
+ // static.mjs — what the agent serves off disk: the vendored client with its
2
+ // own service worker taken out, any client dist dropped into ui/, and the
3
+ // agent's own pages; the headers every response carries; and the two
4
+ // constants every module here shares, where the checkout is and what
5
+ // version it is.
6
+
7
+ import crypto from 'node:crypto';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { wsOrigins } from './origins.mjs';
12
+
13
+ export const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
14
+
15
+ const PHANPY_DIR = path.join(projectRoot, 'phanpy/dist');
16
+ // The vendored client is upstream's build, byte for byte, so the integrity
17
+ // check can say so. The two things it needs changed happen on the way out
18
+ // instead of in the files.
19
+ //
20
+ // vite-plugin-pwa injects this registration into every page it builds. The
21
+ // worker it installs answers navigations from a precache and replays the
22
+ // headers stored at install time, so a CSP change can never reach a browser
23
+ // that already has one.
24
+ const SW_REGISTER = /<script id="vite-plugin-pwa:inline-sw">[\s\S]*?<\/script>/i;
25
+ // Removing it stops new installs. This replaces the worker itself, so the ones
26
+ // already out there clean up: browsers re-fetch sw.js on navigation and
27
+ // install what they find. No fetch handler on purpose — a worker without one
28
+ // never intercepts a request, so pages go straight to the network while it
29
+ // runs.
30
+ const SW_KILL = `self.addEventListener('install', () => self.skipWaiting());
31
+
32
+ self.addEventListener('activate', (event) => {
33
+ event.waitUntil((async () => {
34
+ for (const key of await caches.keys()) await caches.delete(key);
35
+ await self.registration.unregister();
36
+ for (const client of await self.clients.matchAll({ type: 'window' })) {
37
+ try { await client.navigate(client.url); } catch { /* tab will refresh on its own */ }
38
+ }
39
+ })());
40
+ });
41
+ `;
42
+ // Null rather than the original when the tag is gone: an upstream change that
43
+ // silently no-opped here would put the worker back, which is the whole thing
44
+ // this exists to prevent.
45
+ function stripSwRegistration(html) {
46
+ return SW_REGISTER.test(html) ? html.replace(SW_REGISTER, '') : null;
47
+ }
48
+ const UI_DIR = path.join(projectRoot, 'ui'); // extra client dists: ui/<name>/ → /<name>/
49
+ // Our own pages, kept out of ui/ for two reasons: a client dist dropped in
50
+ // there under the same name would shadow them, and a group serves these and
51
+ // nothing else — so the prefix has to be one nobody is invited to write into.
52
+ // One surface, /admin/, with setup as its first section: /admin/setup/ is the
53
+ // first run, /admin/ is the record, and there is room for the rest.
54
+ const WEB_DIR = path.join(projectRoot, 'web');
55
+ const WEB_MOUNTS = ['admin'];
56
+ export const webMount = (pathname) => {
57
+ const seg = decodeURIComponent(pathname).replace(/^\/+/, '').split('/')[0];
58
+ return WEB_MOUNTS.includes(seg) ? seg : null;
59
+ };
60
+ export const SETUP_PAGE = '/admin/setup/';
61
+
62
+ export const AGENT_VERSION = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')).version;
63
+
64
+ const MIME = {
65
+ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css',
66
+ '.json': 'application/json', '.map': 'application/json', '.webmanifest': 'application/manifest+json',
67
+ '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.gif': 'image/gif',
68
+ '.jpg': 'image/jpeg', '.webp': 'image/webp', '.txt': 'text/plain', '.woff2': 'font/woff2',
69
+ };
70
+
71
+ // Sent on every response: nosniff and no-referrer everywhere, and for HTML a
72
+ // CSP that keeps SCRIPTS to our own origin while still allowing the remote
73
+ // avatars, media and instance calls a fediverse client must make. It is not an
74
+ // exfiltration boundary — connect-src has to allow https: for the client to
75
+ // work at all — it is a code-execution one.
76
+ // Phanpy's index.html carries an inline bootstrap script. Rather than
77
+ // opening the policy with 'unsafe-inline', hash the inline scripts we
78
+ // actually ship and allow exactly those.
79
+ let inlineHashes = null;
80
+ function inlineScriptHashes() {
81
+ if (inlineHashes) return inlineHashes;
82
+ inlineHashes = [];
83
+ try {
84
+ // The page as it is SERVED, not as it sits on disk: hashing the
85
+ // registration script we strip would allow a script nobody gets.
86
+ const raw = fs.readFileSync(path.join(PHANPY_DIR, 'index.html'), 'utf8');
87
+ const html = stripSwRegistration(raw) ?? raw;
88
+ for (const m of html.matchAll(/<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/gi)) {
89
+ const digest = crypto.createHash('sha256').update(m[1], 'utf8').digest('base64');
90
+ inlineHashes.push(`'sha256-${digest}'`);
91
+ }
92
+ } catch { /* no inline scripts to allow */ }
93
+ return inlineHashes;
94
+ }
95
+
96
+ export function securityHeaders(auth, isHtml) {
97
+ const h = {
98
+ 'x-content-type-options': 'nosniff',
99
+ 'referrer-policy': 'no-referrer',
100
+ // SAMEORIGIN, not DENY: /admin/client/ frames the bundled client so a bar
101
+ // of ours can sit above it. Only pages on this agent's own origins may —
102
+ // the same set the Host/Origin firewall already trusts.
103
+ 'x-frame-options': 'SAMEORIGIN',
104
+ };
105
+ if (isHtml) {
106
+ h['content-security-policy'] = [
107
+ "default-src 'self'",
108
+ `script-src 'self' 'wasm-unsafe-eval' ${inlineScriptHashes().join(' ')}`,
109
+ "style-src 'self' 'unsafe-inline'",
110
+ "img-src 'self' https: data: blob:",
111
+ "media-src 'self' https: data: blob:",
112
+ "font-src 'self' data:",
113
+ // Every authority the Host/Origin firewall accepts, so browsing an agent
114
+ // at its own name (solo.localhost:8041, a tailnet host) keeps streaming.
115
+ // Pinning this to localhost blocked the socket with no visible error.
116
+ //
117
+ // `https:` is still here and the comment above no longer claims otherwise:
118
+ // a fediverse client fetches remote instances by design — link previews,
119
+ // an actor's own server, media — so there is no narrower set that leaves
120
+ // it working. The XSS story is `script-src 'self'` plus hashes; treat
121
+ // connect-src as availability, not containment.
122
+ `connect-src 'self' https: ${(typeof auth.wsAuthorities === 'function'
123
+ ? auth.wsAuthorities()
124
+ : wsOrigins(auth.port, auth.labels())).join(' ')}`,
125
+ "frame-ancestors 'self'",
126
+ "base-uri 'self'",
127
+ "form-action 'self'",
128
+ ].join('; ');
129
+ }
130
+ return h;
131
+ }
132
+
133
+ export function sendJson(res, status, obj, auth) {
134
+ res.writeHead(status, { 'content-type': 'application/json', ...securityHeaders(auth, false) });
135
+ res.end(JSON.stringify(obj) + '\n');
136
+ }
137
+
138
+ // Static UI serving, path-jailed. Phanpy owns the root; any other client
139
+ // dist dropped into ui/<name>/ is served at /<name>/. Hash-routed apps:
140
+ // '/' (and directories) get their index.html; anything unknown 404s.
141
+ export function serveStatic(res, pathname, auth) {
142
+ let rel = decodeURIComponent(pathname).replace(/^\/+/, '');
143
+ let baseDir = PHANPY_DIR;
144
+ const uiName = rel.split('/')[0];
145
+ // The mount name is joined to UI_DIR, so it must be CONTAINED by it — the
146
+ // same resolve-then-check sendFile does below, for the same reason.
147
+ //
148
+ // Decoding happens before the split, so a `%2f` in the first segment becomes
149
+ // a real separator afterwards and `..` arrives here as a mount name. It
150
+ // exists, and it is a directory, so baseDir was silently re-based to the
151
+ // project root — and sendFile's jail then enforced containment against THAT,
152
+ // dutifully approving `/..%2fpackage.json`, `/..%2f.git/config` and every
153
+ // source file under it for anyone who could reach the port.
154
+ // Resolved through symlinks, not just lexically: sendFile's own jail has
155
+ // always used realpath, and a lexical check here would still admit a mount
156
+ // that is a link pointing out of ui/.
157
+ let mount = '';
158
+ try {
159
+ if (uiName) {
160
+ const cand = path.resolve(UI_DIR, uiName);
161
+ if (cand.startsWith(UI_DIR + path.sep) && fs.statSync(cand).isDirectory()) {
162
+ const real = fs.realpathSync(cand);
163
+ if (real.startsWith(fs.realpathSync(UI_DIR) + path.sep)) mount = cand;
164
+ }
165
+ }
166
+ } catch { /* no such mount; fall through to the default base */ }
167
+ if (mount) {
168
+ baseDir = mount;
169
+ rel = rel.slice(uiName.length).replace(/^\/+/, '');
170
+ }
171
+ return sendFile(res, baseDir, rel, auth);
172
+ }
173
+
174
+ // '/admin/setup' names a directory, so it needs the slash the browser will
175
+ // resolve relative URLs against. Returns the corrected path, or null.
176
+ export function webDirRedirect(pathname) {
177
+ if (pathname.endsWith('/')) return null;
178
+ const rel = decodeURIComponent(pathname).replace(/^\/+/, '');
179
+ const full = path.normalize(path.join(WEB_DIR, rel));
180
+ if (!full.startsWith(WEB_DIR + path.sep)) return null; // not ours to stat
181
+ try {
182
+ if (fs.statSync(full).isDirectory()) return pathname + '/';
183
+ } catch { /* not a directory here */ }
184
+ return null;
185
+ }
186
+
187
+ // web/<mount>/ → /<mount>/. Same jail, different mount rule.
188
+ export function serveWeb(res, pathname, mount, auth) {
189
+ const rel = decodeURIComponent(pathname).replace(/^\/+/, '').slice(mount.length).replace(/^\/+/, '');
190
+ return sendFile(res, path.join(WEB_DIR, mount), rel, auth);
191
+ }
192
+
193
+ export function sendFile(res, baseDir, rel, auth) {
194
+ if (rel === '' || rel.endsWith('/')) rel += 'index.html';
195
+ const file = path.normalize(path.join(baseDir, rel));
196
+ if (!file.startsWith(baseDir + path.sep) && file !== path.join(baseDir, 'index.html')) {
197
+ res.writeHead(403); res.end(); return true;
198
+ }
199
+ let target = file;
200
+ try {
201
+ if (fs.statSync(target).isDirectory()) target = path.join(target, 'index.html');
202
+ // Resolve symlinks before reading: a link inside a UI dir must not be a
203
+ // way out of the jail.
204
+ const real = fs.realpathSync(target);
205
+ const realBase = fs.realpathSync(baseDir);
206
+ if (!real.startsWith(realBase + path.sep) && real !== path.join(realBase, 'index.html')) {
207
+ res.writeHead(403); res.end(); return true;
208
+ }
209
+ const ext = path.extname(real);
210
+ let body = fs.readFileSync(real);
211
+ // The vendored client, adjusted on the way out rather than in the files.
212
+ if (real.startsWith(fs.realpathSync(PHANPY_DIR))) {
213
+ if (path.basename(real) === 'sw.js') {
214
+ body = Buffer.from(SW_KILL);
215
+ } else if (ext === '.html') {
216
+ const stripped = stripSwRegistration(body.toString('utf8'));
217
+ if (stripped === null) {
218
+ console.error(`refusing to serve ${path.basename(real)}: no service-worker `
219
+ + 'registration to remove — the vendored client changed shape');
220
+ res.writeHead(500, { 'content-type': 'text/plain', ...securityHeaders(auth, false) });
221
+ res.end('the bundled client changed shape; refusing to serve it\n');
222
+ return true;
223
+ }
224
+ body = Buffer.from(stripped);
225
+ }
226
+ }
227
+ // Our own pages are read straight off disk and change whenever the project
228
+ // does. With no cache headers a browser is free to reuse them without
229
+ // asking, so an edited page keeps rendering the old one and looks like the
230
+ // edit never landed. The vendored client dists have hashed filenames and
231
+ // are left alone.
232
+ const ours = real.startsWith(fs.realpathSync(WEB_DIR) + path.sep);
233
+ res.writeHead(200, {
234
+ 'content-type': MIME[ext] || 'application/octet-stream',
235
+ ...(ours ? { 'cache-control': 'no-store' } : {}),
236
+ ...securityHeaders(auth, ext === '.html'),
237
+ });
238
+ res.end(body);
239
+ } catch {
240
+ res.writeHead(404, { 'content-type': 'text/plain', ...securityHeaders(auth, false) });
241
+ res.end('not found\n');
242
+ }
243
+ return true;
244
+ }
@@ -0,0 +1,274 @@
1
+ // surface.mjs — the whole admin surface as one request handler, with
2
+ // nothing that belongs to a process of its own: the preamble every request
3
+ // passes (CORS for the client API, the Host/Origin firewall, the operator's
4
+ // door when embedded), the protocol routes that answer strangers (C2S, the
5
+ // OAuth metadata, nodeinfo, the Mastodon facade), the pages and client
6
+ // served off disk, and the dispatch to the routes modules beside it. Each of
7
+ // those exports get() and post() over the same ctx.
8
+
9
+ import { MastoApi } from '../../client/masto/index.mjs';
10
+ import { C2S } from '../../client/c2s.mjs';
11
+ import { makeC2sAuth } from '../../client/oidc-auth.mjs';
12
+ import { Streaming } from '../../client/streaming.mjs';
13
+ import { nodeinfoPointer, nodeinfoDoc } from '../../core/wire.mjs';
14
+ import { checkRequest } from '../../shared/guard.mjs';
15
+ import { hasCredential } from '../setup.mjs';
16
+ import { localVersion } from '../update.mjs';
17
+ import { projectRoot, AGENT_VERSION, SETUP_PAGE, securityHeaders, sendJson, serveStatic, serveWeb, webDirRedirect, webMount } from './static.mjs';
18
+ import * as owner from './routes/owner.mjs';
19
+ import * as setup from './routes/setup.mjs';
20
+ import * as lifecycle from './routes/lifecycle.mjs';
21
+ import * as gateway from './routes/gateway.mjs';
22
+ import * as social from './routes/social.mjs';
23
+ import * as connections from './routes/connections.mjs';
24
+
25
+ // POSTs an unconfigured agent still answers: /block is worth having before
26
+ // federation starts, and /setup is how it stops being unconfigured.
27
+ // /shutdown is here because stopping an agent that was never set up is exactly
28
+ // the case it exists for; it is in LOCAL_ONLY_POSTS below, so it still answers
29
+ // only to this machine.
30
+ const OPEN_POSTS = new Set(['/block', '/unblock', '/setup', '/setup/check', '/setup/reset', '/shutdown']);
31
+ // Routes that manage a local agent process — spawning siblings, killing this
32
+ // one, moving files on the machine, running setup in a browser. Inside a pod
33
+ // server there is no such process and no such machine: identities come from
34
+ // the server's own configuration, so these are not there to be found.
35
+ const EMBEDDED_CUT = new Set(['/profiles', '/shutdown', '/new-actor', '/start-actor',
36
+ '/state-move', '/setup', '/setup/check', '/setup/reset']);
37
+ // AP_ALLOWED_HOSTS may name a tailnet host or a reverse-proxy domain. The
38
+ // fediverse is welcome there; creating accounts and editing the record is for
39
+ // whoever is sitting at the machine.
40
+ 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']);
41
+
42
+ // A cross-origin form POST needs no CORS preflight, and JSON.parse does not
43
+ // care what Content-Type claimed — so parsing whatever arrived let a visited
44
+ // page reach every write route with a body of its choosing. Our own callers
45
+ // (web/admin/*.js, the CLI) all send application/json; `stop` sends no body at
46
+ // all, which is why an absent type is allowed only for an empty one.
47
+ function readBody(req) {
48
+ return new Promise((resolve, reject) => {
49
+ const ct = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
50
+ const isJson = ct === 'application/json';
51
+ const wrongType = () => reject(new Error('expected content-type: application/json'));
52
+ if (ct && !isJson) { req.resume(); wrongType(); return; }
53
+ let data = '';
54
+ req.on('data', c => {
55
+ data += c;
56
+ if (data.length > 1e6) { reject(new Error('request body too large')); req.destroy(); }
57
+ });
58
+ req.on('end', () => {
59
+ if (data && !isJson) return wrongType();
60
+ try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(e); }
61
+ });
62
+ req.on('error', reject);
63
+ });
64
+ }
65
+
66
+ // The routes, in the order the one if-chain and the one switch used to be
67
+ // read: the first module that answers a path wins.
68
+ const ROUTES = [owner, setup, lifecycle, gateway, social, connections];
69
+
70
+ // The whole admin/facade surface as one request handler, with nothing that
71
+ // belongs to a process of its own. startAdmin wraps it in listeners; the CSS
72
+ // component hands it CSS's, so the same routes answer on the pod's origin.
73
+ //
74
+ // `embedded` cuts the routes that manage local agent processes — they have no
75
+ // meaning inside a server that is not one — and moves the gate: a pod that is
76
+ // a fediverse instance must let strangers reach /api and /oauth, so the gate
77
+ // guards the operator's door (basePath) instead of the whole surface.
78
+ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
79
+ port = null, handle = null, embedded = false, basePath = '/',
80
+ publicOrigin = null, scheme = null,
81
+ versionOnDisk = () => localVersion(projectRoot) }) {
82
+ const json = (res, status, obj) => sendJson(res, status, obj, allowed);
83
+ const masto = new MastoApi({ agent, log, allowed, scheme, embedded });
84
+ // The spec's own write API (§6), beside the facade. Its bearer fallback is
85
+ // the facade's token, so the two surfaces share one notion of the operator.
86
+ const c2s = new C2S({ agent, log, auth: makeC2sAuth({ agent, masto, log, scheme }) });
87
+ const streaming = new Streaming({ masto, log, allowed, gate, gateOptional: embedded });
88
+ // Asked per request, not once here: startAdmin runs before connect, so the
89
+ // kind is not known yet at mount time.
90
+ const isGroup = () => agent.store.getConfig()?.kind === 'group';
91
+ // One setup at a time, and the record outlives the run: a reloaded page
92
+ // must still find out how the run it started ended. `setup.run` is written
93
+ // by the setup routes and read by the setup page's state route.
94
+ const setup_ = { run: null };
95
+ // New statuses/notifications flow to connected streaming clients live.
96
+ agent.store.onEvent = (type, obj) => {
97
+ try {
98
+ if (type === 'status') streaming.broadcast('update', masto.status(obj));
99
+ else if (type === 'notification') {
100
+ streaming.broadcast('notification', masto.notification(obj));
101
+ // and out to any closed client, via its push subscription
102
+ masto.pushNotify(obj).catch(e => log(`webpush: ${e.message}`));
103
+ }
104
+ } catch (e) { log(`streaming broadcast: ${e.message}`); }
105
+ };
106
+
107
+ // A path as the browser must ask for it: behind the door, prefixed with it.
108
+ const atPath = (p_) => (basePath === '/' ? p_ : basePath.slice(0, -1) + p_);
109
+
110
+ // What every route may reach: the agent and the deployment's facts.
111
+ const ctx = { agent, log, allowed, embedded, port, handle, publicOrigin, versionOnDisk, isGroup, json, setup: setup_ };
112
+
113
+ const handler = async (req, res) => {
114
+ const url = new URL(req.url, 'http://localhost');
115
+ // Mastodon-style: the bearer-gated client API and the OAuth + nodeinfo
116
+ // routes answer any origin — a browser client is served the way any
117
+ // instance serves it. CORS headers and the preflight make that work; the
118
+ // bearer stays the only credential, and the Host check below (which is
119
+ // what stops DNS rebinding) still runs.
120
+ const apiPath = url.pathname.startsWith('/api/') || url.pathname.startsWith('/oauth/')
121
+ || url.pathname === '/.well-known/nodeinfo' || url.pathname === '/nodeinfo/2.0';
122
+ if (apiPath) {
123
+ res.setHeader('access-control-allow-origin', '*');
124
+ res.setHeader('access-control-expose-headers', 'Link');
125
+ if (req.method === 'OPTIONS') {
126
+ res.writeHead(204, {
127
+ 'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
128
+ 'access-control-allow-headers': 'Authorization, Content-Type, Idempotency-Key',
129
+ 'access-control-max-age': '86400',
130
+ });
131
+ res.end();
132
+ return;
133
+ }
134
+ }
135
+ // Host/Origin firewall: loopback binding alone does not keep a visited web
136
+ // page (or a rebound DNS name) out. The API paths keep the Host check but
137
+ // answer a foreign Origin, per above.
138
+ const bad = checkRequest(req, allowed, { ignoreOrigin: apiPath });
139
+ if (bad) {
140
+ log(`refused: ${bad} (${req.method} ${req.url})`);
141
+ res.writeHead(403, { 'content-type': 'text/plain', ...securityHeaders(allowed, false) });
142
+ res.end('forbidden\n');
143
+ return;
144
+ }
145
+ let p = url.pathname;
146
+ // Embedded, the operator's door is one path on the pod's origin. Behind it
147
+ // is everything that was the admin server; in front of it are the protocol
148
+ // routes, which have to answer strangers because that is what makes the pod
149
+ // an instance other software can talk to.
150
+ let atDoor = !embedded;
151
+ if (embedded && basePath !== '/'
152
+ && (p === basePath.slice(0, -1) || p.startsWith(basePath))) {
153
+ atDoor = true;
154
+ p = p.slice(basePath.length - 1) || '/';
155
+ }
156
+ if (embedded && atDoor && EMBEDDED_CUT.has(p)) {
157
+ return json(res, 404, { error: 'not available on a server-hosted identity' });
158
+ }
159
+ try {
160
+ // C2S (ActivityPub §6) carries its own authentication — a Solid-OIDC
161
+ // DPoP proof or the facade's bearer — so the dk-token gate does not
162
+ // stand in front of it. The Host/Origin firewall above still does.
163
+ if (p === '/ap/outbox' || p === '/ap/actor' || p === '/ap/inbox') {
164
+ if (await c2s.handle(req, res, p, url)) return;
165
+ }
166
+ // Where a client looks first to find out how to sign in (RFC 8414), and
167
+ // in front of the door for the same reason C2S is: a client that has to
168
+ // be handed a secret before it can ask how to sign in cannot set itself
169
+ // up at all. It names endpoints and nothing else, the endpoints it names
170
+ // refuse without a password anyway, and the host and origin firewall
171
+ // above still decides who gets this far.
172
+ if (p === '/.well-known/oauth-authorization-server') {
173
+ const scheme = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
174
+ return json(res, 200, masto.authorizationServerMetadata(`${scheme}://${req.headers.host}`));
175
+ }
176
+ if (atDoor && gate(req, res)) return;
177
+ if (p === '/api/v1/streaming/health') {
178
+ res.writeHead(200, { 'content-type': 'text/plain' }); res.end('OK'); return;
179
+ }
180
+ // NodeInfo on the agent origin — clients probe it at login.
181
+ if (p === '/.well-known/nodeinfo') {
182
+ return json(res, 200, nodeinfoPointer(
183
+ `${req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'}://${req.headers.host}/nodeinfo/2.0`));
184
+ }
185
+ if (p === '/nodeinfo/2.0') {
186
+ return json(res, 200, nodeinfoDoc({
187
+ version: AGENT_VERSION,
188
+ localPosts: agent.store.countStatuses('post'),
189
+ }));
190
+ }
191
+ // A group serves the client too, as of 2026-08-01. It was withheld on the
192
+ // reasoning that a group has no timeline a human reads — which was wrong
193
+ // twice over: a group has statuses (what it carried) and notifications
194
+ // (who joined), and its operator has a bio to edit and a profile they
195
+ // want to see the way everyone else does. The surface this opens is a
196
+ // login and client tokens, and `passwd` has never had a group carve-out,
197
+ // so a group can be gated exactly like a person before it is exposed.
198
+ if (p.startsWith('/api/') || p.startsWith('/oauth/')) {
199
+ if (await masto.handle(req, res, p, url)) return;
200
+ }
201
+ if (embedded && !atDoor) return json(res, 404, { error: 'unknown endpoint' });
202
+ if (req.method === 'GET') {
203
+ for (const r of ROUTES) if (await r.get?.(p, ctx, req, res, url)) return;
204
+ }
205
+ if (req.method === 'GET' || req.method === 'HEAD') {
206
+ // Our own pages come before the group check: a group is set up in the
207
+ // browser like anything else, and it has a record to edit. It still
208
+ // serves no fediverse client — see the 404 two lines down.
209
+ const mount = webMount(p);
210
+ if (mount) {
211
+ // Without the slash a page's own relative <script src> resolves one
212
+ // level up and 404s — and that is true at any depth, so ask the
213
+ // filesystem rather than only special-casing the mount itself.
214
+ const asDir = webDirRedirect(p);
215
+ if (asDir) {
216
+ res.writeHead(302, { location: atPath(asDir), ...securityHeaders(allowed, false) });
217
+ res.end();
218
+ return;
219
+ }
220
+ return serveWeb(res, p, mount, allowed);
221
+ }
222
+ // The bare URL means "show me what this agent wants from me now".
223
+ // Keyed on the credential FILE, never on configured(): a healthy
224
+ // install whose pod is briefly unreachable reports itself
225
+ // unconfigured for up to an hour, and must not be sent to setup.
226
+ if (p === '/' || p === '/index.html') {
227
+ if (!(agent.home && hasCredential(agent.home))) {
228
+ res.writeHead(302, { location: atPath(SETUP_PAGE), ...securityHeaders(allowed, false) });
229
+ res.end();
230
+ return;
231
+ }
232
+ // Opening the bare origin gets this actor's own client — the framed
233
+ // view with the bar — not the unbound app. The client page frames
234
+ // `/` itself and that load says so (Sec-Fetch-Dest: iframe), so only
235
+ // a top-level navigation is sent onward. The one top-level landing
236
+ // that must NOT be sent onward is the OAuth return, `/?code=…`: the
237
+ // client registered `/` as its redirect URI and only the app at `/`
238
+ // can exchange the code — the framed page would drop it and leave
239
+ // the client logged out.
240
+ const q = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
241
+ const oauthLanding = /[?&](code|error)=/.test(q);
242
+ if (!oauthLanding && req.headers['sec-fetch-dest'] === 'document') {
243
+ res.writeHead(302, { location: atPath('/admin/client/') + q, ...securityHeaders(allowed, false) });
244
+ res.end();
245
+ return;
246
+ }
247
+ }
248
+ return serveStatic(res, p, allowed);
249
+ }
250
+ if (req.method !== 'POST') return json(res, 405, { error: 'POST only' });
251
+ // A body we refuse to read is the caller's mistake, not ours — 400 says
252
+ // so, where the catch-all below would have called it a server fault.
253
+ let body;
254
+ try { body = await readBody(req); }
255
+ catch (e) { return json(res, 400, { error: e.message }); }
256
+ if (!OPEN_POSTS.has(p) && !agent.configured()) {
257
+ return json(res, 409, { error: 'agent not configured — set it up at /admin/setup/' });
258
+ }
259
+ if (!embedded && LOCAL_ONLY_POSTS.has(p) && !allowed.isLocalRequest(req)) {
260
+ return json(res, 403, { error: 'setup and configuration are available on this machine only' });
261
+ }
262
+ for (const r of ROUTES) if (await r.post?.(p, body, ctx, req, res)) return;
263
+ return json(res, 404, { error: 'unknown endpoint' });
264
+ } catch (e) {
265
+ // The caller gets the real message: this server binds loopback only, sets
266
+ // no CORS headers, and its one reader is the operator. Hiding the reason
267
+ // from them buys nothing and costs a trip to the log. Stack stays here.
268
+ log(`admin ${p}: ${e.stack || e.message}`);
269
+ return json(res, 500, { error: e.message || String(e) });
270
+ }
271
+ };
272
+
273
+ return { handler, masto, c2s, streaming };
274
+ }