fedipod 0.19.0 → 1.2.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 (149) hide show
  1. package/README.md +104 -134
  2. package/architecture.md +41 -0
  3. package/bin/fedipod.mjs +45 -2253
  4. package/browser.svg +72 -0
  5. package/cli.md +0 -4
  6. package/gateway.md +160 -0
  7. package/groups.md +0 -6
  8. package/gui.md +7 -11
  9. package/installed-agent.md +97 -0
  10. package/lib/{c2s.mjs → client/c2s.mjs} +8 -3
  11. package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
  12. package/lib/client/masto/accounts.mjs +264 -0
  13. package/lib/client/masto/body.mjs +69 -0
  14. package/lib/client/masto/index.mjs +183 -0
  15. package/lib/client/masto/instance.mjs +104 -0
  16. package/lib/client/masto/media.mjs +133 -0
  17. package/lib/client/masto/oauth.mjs +599 -0
  18. package/lib/client/masto/render.mjs +459 -0
  19. package/lib/client/masto/statuses.mjs +331 -0
  20. package/lib/client/masto/timelines.mjs +316 -0
  21. package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
  22. package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
  23. package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
  24. package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
  25. package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
  26. package/lib/{import.mjs → connections/import.mjs} +1 -1
  27. package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +3 -3
  28. package/lib/connections/vault.mjs +114 -0
  29. package/lib/core/as2.mjs +124 -0
  30. package/lib/core/contexts/activitystreams.json +379 -0
  31. package/lib/core/contexts/did-v1.json +57 -0
  32. package/lib/core/contexts/fep-5711.json +36 -0
  33. package/lib/core/contexts/gotosocial.json +86 -0
  34. package/lib/core/contexts/identity-v1.json +152 -0
  35. package/lib/core/contexts/index.mjs +45 -0
  36. package/lib/core/contexts/join-lemmy.json +33 -0
  37. package/lib/core/contexts/joinmastodon.json +28 -0
  38. package/lib/core/contexts/map.json +16 -0
  39. package/lib/core/contexts/miscellany.json +19 -0
  40. package/lib/core/contexts/schemaorg.json +8845 -0
  41. package/lib/core/contexts/security-data-integrity-v1.json +78 -0
  42. package/lib/core/contexts/security-data-integrity-v2.json +81 -0
  43. package/lib/core/contexts/security-multikey-v1.json +35 -0
  44. package/lib/core/contexts/security-v1.json +74 -0
  45. package/lib/core/contexts/webfinger.json +10 -0
  46. package/lib/{deliver.mjs → core/deliver.mjs} +2 -2
  47. package/lib/core/intake/activities.mjs +437 -0
  48. package/lib/core/intake/activity.mjs +240 -0
  49. package/lib/core/intake/channel.mjs +144 -0
  50. package/lib/core/intake/group.mjs +222 -0
  51. package/lib/core/intake/index.mjs +629 -0
  52. package/lib/core/intake/notes.mjs +288 -0
  53. package/lib/core/intake/verify.mjs +141 -0
  54. package/lib/{keys.mjs → core/keys.mjs} +1 -1
  55. package/lib/core/publisher/collections.mjs +229 -0
  56. package/lib/core/publisher/index.mjs +421 -0
  57. package/lib/core/publisher/notes.mjs +188 -0
  58. package/lib/core/publisher/questions.mjs +233 -0
  59. package/lib/core/publisher/restore.mjs +196 -0
  60. package/lib/core/shapes/activitystreams.ttl +129 -0
  61. package/lib/core/shapes/index.mjs +107 -0
  62. package/lib/core/shapes/shapes-text.mjs +13 -0
  63. package/lib/{social.mjs → core/social.mjs} +2 -2
  64. package/lib/{store.mjs → core/store.mjs} +4 -0
  65. package/lib/{wire.mjs → core/wire.mjs} +2 -2
  66. package/lib/device/admin/index.mjs +13 -0
  67. package/lib/device/admin/origins.mjs +35 -0
  68. package/lib/device/admin/routes/connections.mjs +144 -0
  69. package/lib/device/admin/routes/gateway.mjs +199 -0
  70. package/lib/device/admin/routes/lifecycle.mjs +191 -0
  71. package/lib/device/admin/routes/owner.mjs +322 -0
  72. package/lib/device/admin/routes/setup.mjs +393 -0
  73. package/lib/device/admin/routes/social.mjs +188 -0
  74. package/lib/device/admin/server.mjs +95 -0
  75. package/lib/device/admin/static.mjs +244 -0
  76. package/lib/device/admin/surface.mjs +274 -0
  77. package/lib/device/cli/commands/account.mjs +586 -0
  78. package/lib/device/cli/commands/run.mjs +278 -0
  79. package/lib/device/cli/commands/service.mjs +221 -0
  80. package/lib/device/cli/commands/setup.mjs +410 -0
  81. package/lib/device/cli/commands/state.mjs +559 -0
  82. package/lib/device/cli/context.mjs +288 -0
  83. package/lib/{migrate.mjs → device/migrate.mjs} +1 -1
  84. package/lib/{remote.mjs → device/remote.mjs} +3 -3
  85. package/lib/{setup.mjs → device/setup.mjs} +3 -3
  86. package/lib/{update.mjs → device/update.mjs} +1 -1
  87. package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
  88. package/lib/{front-core.mjs → gateway/front-core.mjs} +3 -3
  89. package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +1 -1
  90. package/lib/{httpsig.mjs → gateway/httpsig.mjs} +1 -1
  91. package/lib/{embed.mjs → server/embed.mjs} +131 -22
  92. package/lib/{links.mjs → shared/links.mjs} +1 -1
  93. package/lib/{ua.mjs → shared/ua.mjs} +1 -1
  94. package/package.json +16 -2
  95. package/run-agent.mjs +33 -25
  96. package/scripts/build-app.mjs +4 -0
  97. package/scripts/check-pod-calls.mjs +8 -2
  98. package/scripts/refresh-contexts.mjs +39 -0
  99. package/web/admin/actors.js +145 -0
  100. package/web/admin/common.js +23 -0
  101. package/web/admin/connections.js +112 -0
  102. package/web/admin/gateway.js +111 -0
  103. package/web/admin/group.js +258 -0
  104. package/web/admin/index.html +7 -1
  105. package/web/admin/record.js +378 -0
  106. package/web/admin/setup/index.html +1 -0
  107. package/web/admin/setup/setup.js +2 -13
  108. package/web/admin/upkeep.js +170 -0
  109. package/web/app/README.md +6 -6
  110. package/web/app/admin-facade.mjs +3 -3
  111. package/web/app/agent.mjs +12 -12
  112. package/web/app/atproto-browser.mjs +1 -1
  113. package/web/app/deliver-relay.mjs +1 -1
  114. package/web/app/dist/sw.js +21684 -5415
  115. package/web/app/dist/sw.js.map +4 -4
  116. package/web/app/fediacct-browser.mjs +1 -1
  117. package/web/app/shims/shapes-text.mjs +8 -0
  118. package/web/app/site/admin/actors.js +145 -0
  119. package/web/app/site/admin/common.js +23 -0
  120. package/web/app/site/admin/connections.js +112 -0
  121. package/web/app/site/admin/gateway.js +111 -0
  122. package/web/app/site/admin/group.js +258 -0
  123. package/web/app/site/admin/index.html +7 -1
  124. package/web/app/site/admin/record.js +378 -0
  125. package/web/app/site/admin/setup/index.html +1 -0
  126. package/web/app/site/admin/setup/setup.js +2 -13
  127. package/web/app/site/admin/upkeep.js +170 -0
  128. package/web/app/site/sw.js +21684 -5415
  129. package/web/app/sw-src.mjs +17 -2
  130. package/lib/admin.mjs +0 -1913
  131. package/lib/intake.mjs +0 -1981
  132. package/lib/mastoapi.mjs +0 -2284
  133. package/lib/publisher.mjs +0 -1192
  134. package/web/admin/admin.js +0 -1181
  135. package/web/app/site/admin/admin.js +0 -1181
  136. /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
  137. /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
  138. /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
  139. /package/lib/{lease.mjs → core/lease.mjs} +0 -0
  140. /package/lib/{polls.mjs → core/polls.mjs} +0 -0
  141. /package/lib/{proof.mjs → core/proof.mjs} +0 -0
  142. /package/lib/{storage.mjs → core/storage.mjs} +0 -0
  143. /package/lib/{account.mjs → device/account.mjs} +0 -0
  144. /package/lib/{certs.mjs → device/certs.mjs} +0 -0
  145. /package/lib/{export-collections.mjs → device/export-collections.mjs} +0 -0
  146. /package/lib/{home.mjs → device/home.mjs} +0 -0
  147. /package/lib/{ports.mjs → device/ports.mjs} +0 -0
  148. /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
  149. /package/lib/{safefetch.mjs → shared/safefetch.mjs} +0 -0
@@ -0,0 +1,288 @@
1
+ // context.mjs — what every command starts from: the arguments, which
2
+ // identity (HOME) and port (PORT) a command means, and the helpers that ask,
3
+ // probe and finish. HOME, PORT and DEFAULT_ISSUE are live bindings: useProfile()
4
+ // reassigns them and every command module sees the new value.
5
+
6
+ import fs from 'node:fs';
7
+ import { localFetch } from '../../../lib/client/localapi.mjs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import net from 'node:net';
11
+ import readline from 'node:readline';
12
+ import { spawn } from 'node:child_process';
13
+ import { fileURLToPath, pathToFileURL } from 'node:url';
14
+ import { apRoot, profilesDir, identityHomes, isLegacyRoot, CURRENT_ROOT, tildify, rootOf,
15
+ readRoot, writeRoot, defaultProfile, profileHome, rootHoldsIdentity, ROOT_FILE,
16
+ recordLastUsed, writeJsonAtomic } from '../../../lib/device/home.mjs';
17
+ import { insecureUrlReason } from '../../../lib/shared/safefetch.mjs';
18
+ import { portFree, freePortFrom } from '../../../lib/device/ports.mjs';
19
+
20
+ export const args = process.argv.slice(2);
21
+ export const cmd = args[0];
22
+ export const flag = (name, dflt) => {
23
+ const i = args.indexOf('--' + name);
24
+ return i >= 0 ? args[i + 1] : dflt;
25
+ };
26
+ export const has = (name) => args.includes('--' + name);
27
+ // The port can also be given bare — `npm start 8081` reaches us as `up 8081`,
28
+ // because npm passes positionals through but eats `--port`. Bare form only for
29
+ // the start-style commands, where a lone number cannot mean anything else.
30
+ export const barePort = () => {
31
+ if (!['up', 'start', 'run'].includes(cmd)) return null;
32
+ for (let i = 1; i < args.length; i++) {
33
+ if (/^\d+$/.test(args[i]) && !args[i - 1].startsWith('--')) return args[i];
34
+ }
35
+ return null;
36
+ };
37
+ export const portFlag = () => flag('port', null) || barePort();
38
+ // One identity per home, and EVERY identity is `<root>/profiles/<name>/`. There
39
+ // is no privileged unnamed one: `root.json` names which you get when you do not
40
+ // say, and that is a pointer you can change rather than a directory you have to
41
+ // move a private key out of. lib/home.mjs decides the root.
42
+ //
43
+ // An explicit --home / AP_HOME still wins and is taken literally: you named a
44
+ // directory, so that directory is the identity, root.json unread. That is what
45
+ // `rootOf` has always documented for a custom home.
46
+ export const PROFILE = flag('profile', process.env.AP_PROFILE || null);
47
+ export const AP_ROOT = apRoot();
48
+ export const PROFILES_DIR = profilesDir(AP_ROOT);
49
+
50
+ // `let`, because setup does not know which identity it is until it has asked for
51
+ // the handle — the home is named after it. Everything below reads HOME at call
52
+ // time, so reassigning it once, early, is enough; PORT is the exception and is
53
+ // recomputed with it.
54
+ export let DEFAULT_ISSUE = null; // set when the pointer is unusable
55
+ export let HOME = flag('home', process.env.AP_HOME || (() => {
56
+ if (PROFILE) return profileHome(AP_ROOT, PROFILE);
57
+ const d = defaultProfile(AP_ROOT);
58
+ if (typeof d === 'string') return profileHome(AP_ROOT, d);
59
+ DEFAULT_ISSUE = d?.missing
60
+ ? `${ROOT_FILE} names "${d.missing}", which is not an identity here`
61
+ : 'there is more than one identity here and none is the default';
62
+ return profileHome(AP_ROOT, d?.missing || '');
63
+ })());
64
+
65
+ // The port chosen at setup is remembered, so `start`/`stop`/`status` need no
66
+ // flags afterwards. Precedence: --port (or a bare port number, `npm start
67
+ // 8081`) > AP_PORT > the recorded choice > 8030.
68
+ // The handle is remembered alongside it, for the named origin: the agent also
69
+ // answers at <handle>.localhost:<port>, and that has to work from the first
70
+ // request, before pod state has been read.
71
+ export function recordedAgent() {
72
+ try { return JSON.parse(fs.readFileSync(path.join(HOME, 'agent.json'), 'utf8')) || {}; }
73
+ catch { return {}; }
74
+ }
75
+ export function recordedPort() { return Number(recordedAgent().port) || null; }
76
+ export function recordAgent(fields) {
77
+ try {
78
+ fs.mkdirSync(HOME, { recursive: true, mode: 0o700 });
79
+ const rec = { ...recordedAgent(), ...fields };
80
+ writeJsonAtomic(path.join(HOME, 'agent.json'), rec, { mode: 0o644 });
81
+ } catch { /* the flag still works, it just isn't remembered */ }
82
+ }
83
+ export let PORT = Number(portFlag() || process.env.AP_PORT || recordedPort() || 8030);
84
+
85
+ // Setup is the one command that cannot know its home in advance: the identity is
86
+ // named after the handle, and the handle is the first thing it asks. Everything
87
+ // that reads HOME does so at call time, so pointing it at the right directory as
88
+ // soon as the name exists is enough — PORT is recomputed because it was read
89
+ // from the old home's agent.json.
90
+ export function useProfile(name) {
91
+ HOME = flag('home', process.env.AP_HOME || profileHome(AP_ROOT, name));
92
+ PORT = Number(portFlag() || process.env.AP_PORT || recordedPort() || 8030);
93
+ DEFAULT_ISSUE = null;
94
+ return HOME;
95
+ }
96
+
97
+ // A handle becomes a directory name, so it is checked before it is one. Same
98
+ // rule the admin API applies before creating an actor (admin.mjs) — without it
99
+ // a handle containing a slash or `..` climbs out of profiles/.
100
+ const HANDLE_RE = /^[a-z0-9][a-z0-9_-]{0,30}$/;
101
+ export function requireHandle(handle) {
102
+ if (HANDLE_RE.test(handle)) return handle;
103
+ console.error(`"${handle}" cannot be a handle: letters, digits, hyphens and underscores,`);
104
+ console.error('starting with a letter or digit, at most 31 characters.');
105
+ process.exit(2);
106
+ }
107
+
108
+ // Commands that act on an identity need one decided. Says which of the three
109
+ // ways it failed, because "no identity" and "which identity" are different
110
+ // problems with different fixes.
111
+ export function requireIdentity() {
112
+ // An explicit AP_HOME / --home is an explicit identity directory. Nothing
113
+ // about the machine's root applies to it — including whether that root has
114
+ // been restructured, which is somebody else's install's problem.
115
+ if (process.env.AP_HOME || flag('home')) return;
116
+ if (rootHoldsIdentity(AP_ROOT)) {
117
+ console.error(`${tildify(AP_ROOT)} still keeps an identity at its top level.`);
118
+ console.error('Every identity lives in profiles/<name>/ now. Move this one down with:\n');
119
+ console.error(` ${process.argv[1]} home --restructure\n`);
120
+ process.exit(2);
121
+ }
122
+ if (!DEFAULT_ISSUE) return;
123
+ const homes = identityHomes(AP_ROOT).filter(h => fs.existsSync(path.join(h.dir, 'credential.json')));
124
+ console.error(DEFAULT_ISSUE + '.');
125
+ if (homes.length) {
126
+ console.error(`\n ${process.argv[1]} --profile <name> start\n`);
127
+ console.error('Whichever you start is remembered, so plain commands mean that one afterwards.');
128
+ console.error(`here: ${homes.map(h => h.name).join(', ')}`);
129
+ } else {
130
+ console.error(`\nThere are no identities yet — ${process.argv[1]} setup`);
131
+ }
132
+ process.exit(2);
133
+ }
134
+
135
+ export function askHidden(prompt) {
136
+ return new Promise((resolve) => {
137
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
138
+ const onData = (c) => { if (String(c) !== '\n' && String(c) !== '\r') readline.moveCursor(process.stdout, -1, 0), process.stdout.write('*'); };
139
+ process.stdin.on('data', onData);
140
+ rl.question(prompt, (answer) => { process.stdin.off('data', onData); rl.close(); process.stdout.write('\n'); resolve(answer); });
141
+ });
142
+ }
143
+
144
+ // Plain prompt with a default: Enter accepts it. Non-interactive runs
145
+ // (scripts, CI) take the default silently, so flags remain sufficient.
146
+ let sharedRl = null; // one interface: a new one per
147
+ export function ask(prompt, dflt = '') { // question would drop buffered input
148
+ if (!process.stdin.isTTY) return Promise.resolve(dflt);
149
+ sharedRl ||= readline.createInterface({ input: process.stdin, output: process.stdout });
150
+ return new Promise((resolve) => {
151
+ sharedRl.question(dflt ? `${prompt} [${dflt}]: ` : `${prompt}: `, (answer) => {
152
+ resolve(String(answer).trim() || dflt);
153
+ });
154
+ });
155
+ }
156
+ export function endAsking() { sharedRl?.close(); sharedRl = null; }
157
+
158
+ // "Occupied" means "cannot be bound", not "does not answer HTTP": something
159
+ // holding a port without speaking HTTP reads as free to a GET, and then the
160
+ // agent dies on EADDRINUSE.
161
+
162
+ // The first port from `first` upward that binds. Walking always ends in one,
163
+ // so this is a step rather than a condition.
164
+
165
+ // Is anything at all on this port? For the operations that MOVE data, "it did
166
+ // not answer as one of ours" is not the same as "nothing is there": an agent
167
+ // started with AP_GATE_TOKEN answers 401 to an un-tokened /status, so agentOn
168
+ // reads a perfectly live agent as stopped — and a sweep that trusted it would
169
+ // copy the state out from under one, which the next write then overwrites.
170
+ // Bind to find out, and fail closed.
171
+ export async function somethingOn(port) {
172
+ const mine = await agentOn(port);
173
+ if (mine) return 'running';
174
+ return (await portFree(port)) ? null : 'something is on the port and did not answer as ours';
175
+ }
176
+
177
+ // Whatever is on the port — is it one of ours?
178
+ export async function agentOn(port) {
179
+ try {
180
+ const res = await localFetch(HOME, port, `/status`, { signal: AbortSignal.timeout(2000) });
181
+ const body = await res.json();
182
+ return typeof body?.configured === 'boolean' ? body : null;
183
+ } catch { return null; }
184
+ }
185
+
186
+ export const isInside = (root, p) => {
187
+ const rel = path.relative(root, p);
188
+ return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
189
+ };
190
+
191
+ export function openBrowser(url) {
192
+ try {
193
+ const win = process.platform === 'win32';
194
+ const cmd = win ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open';
195
+ const child = spawn(cmd, win ? ['/c', 'start', '', url] : [url], { detached: true, stdio: 'ignore' });
196
+ // A box with no opener at all (a server, a bare container) emits this
197
+ // asynchronously, where the try/catch cannot reach it — and an unhandled
198
+ // 'error' event on a child process ends the agent.
199
+ child.on('error', () => {});
200
+ child.unref();
201
+ } catch { /* best-effort — the URL is printed anyway */ }
202
+ }
203
+
204
+ // A one-shot command holds the lease only for its own duration: exiting without
205
+ // releasing it leaves the next `start` as a read-only viewer until the lease
206
+ // expires, which is 5 minutes of doing nothing.
207
+ export async function finish(agent, code = 0) {
208
+ await agent.lease?.release().catch(() => {});
209
+ await agent.store?.flush?.().catch(() => {});
210
+ process.exit(code);
211
+ }
212
+
213
+ // Anything that decides the identity. Given even one of these, setup stays on
214
+ // the command line exactly as it always did — scripts, CI and the tarball's
215
+ // unpack-and-go line depend on that. At a terminal with none of them, setup
216
+ // asks the two things it needs to open a browser and asks the rest there.
217
+ export const IDENTITY_FLAGS = ['new-account', 'pod', 'issuer', 'email', 'name', 'pod-name',
218
+ 'group', 'approve-joins', 'summary', 'icon', 'root', 'keys', 'rotate-key'];
219
+
220
+ // Refuse before anything is asked, let alone typed: setup used to overwrite
221
+ // credential.json in place, and a minted credential is only shown once — so
222
+ // the identity it belonged to could not be recovered afterwards.
223
+ export function refuseExistingIdentity() {
224
+ const credPath = path.join(HOME, 'credential.json');
225
+ if (!fs.existsSync(credPath) || has('force')) return;
226
+ let held = '(unreadable)';
227
+ try { held = JSON.parse(fs.readFileSync(credPath, 'utf8')).remotePod; } catch {}
228
+ console.error(`${HOME} already holds an identity: ${held}`);
229
+ console.error('For another identity: fedipod setup --profile <name>');
230
+ console.error('To list what exists: fedipod profiles');
231
+ console.error('To replace this one: add --force (the old credential is lost)');
232
+ console.error('');
233
+ console.error('If a setup died half-way, do NOT re-run it — the credential it already');
234
+ console.error('minted cannot be minted twice. Run `fedipod start` and');
235
+ console.error('finish at /admin/setup/ in the browser.');
236
+ process.exit(2);
237
+ }
238
+
239
+ // Ask the handle (permanent, and it names the origin) and the port, start
240
+ // serving, and hand over to the page at /admin/setup/. Nothing is created here: the
241
+ // agent's own POST /setup does all of it, so a closed tab cannot lose a
242
+ // credential that only exists in an HTTP response.
243
+ export async function runBrowserSetup() {
244
+ const handle = flag('handle') || await ask('handle (the name in your address; permanent)');
245
+ if (!handle) { console.error('a handle is required'); process.exit(2); }
246
+ // The handle names the home, so nothing can be decided before it — including
247
+ // which identity would be overwritten, and which port was remembered.
248
+ requireHandle(handle);
249
+ useProfile(PROFILE || handle);
250
+ refuseExistingIdentity();
251
+ const port = portFlag() ? PORT : (Number(await ask('port', String(PORT))) || PORT);
252
+ endAsking();
253
+
254
+ // Recorded before the server starts, so `stop`/`status` work while the
255
+ // browser flow is still open — it used to be written only after the mint.
256
+ recordAgent({ port, handle });
257
+
258
+ const { Agent } = await import(new URL('../../../run-agent.mjs', import.meta.url));
259
+ const { startAdmin } = await import(new URL('../../../lib/device/admin/index.mjs', import.meta.url));
260
+ const { hostLabel } = await import(new URL('../../../lib/shared/guard.mjs', import.meta.url));
261
+ const agent = new Agent({ home: HOME, log: (...a) => console.log('[ap]', ...a) });
262
+ startAdmin({
263
+ port, handle, agent,
264
+ gateToken: process.env.AP_GATE_TOKEN || '',
265
+ log: (...a) => console.log('[ap]', ...a),
266
+ });
267
+ const shutdown = () => {
268
+ setTimeout(() => process.exit(0), 1500).unref();
269
+ try { fs.rmSync(path.join(HOME, 'agent.pid'), { force: true }); } catch {}
270
+ Promise.allSettled([agent.store.flush(), agent.lease?.release()]).finally(() => process.exit(0));
271
+ };
272
+ process.on('SIGINT', shutdown);
273
+ process.on('SIGTERM', shutdown);
274
+
275
+ const label = hostLabel(handle);
276
+ const named = label ? `https://${label}.localhost:${port}/` : null;
277
+ const plain = `https://localhost:${port}/`;
278
+ const pad = Math.max(named?.length || 0, plain.length);
279
+ console.log('');
280
+ if (named) {
281
+ console.log(` ${named.padEnd(pad)} <- opening this`);
282
+ console.log(` ${plain.padEnd(pad)} <- the same agent, if your browser cannot find that name`);
283
+ } else {
284
+ console.log(` ${plain}`);
285
+ }
286
+ console.log('\nsetup continues in the browser — Ctrl-C to stop\n');
287
+ openBrowser(named || plain);
288
+ }
@@ -99,7 +99,7 @@ export function classifyRemoteState(urls, base) {
99
99
  // a failure part-way leaves the source authoritative.
100
100
  // Used by both `state --to` and the record page's Move private data.
101
101
  export async function copyPrivateHalf({ from, to, log = () => {} }) {
102
- const { PodStore } = await import('./store.mjs');
102
+ const { PodStore } = await import('../core/store.mjs');
103
103
  const src = new PodStore({ log: () => {} });
104
104
  src.attach(from.state);
105
105
  await src.load();
@@ -12,10 +12,10 @@
12
12
  import { createRequire } from 'node:module';
13
13
  import path from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
- import { PodTransport } from './pod/transport.mjs';
15
+ import { PodTransport } from '../pod/transport.mjs';
16
16
 
17
17
  const require = createRequire(import.meta.url);
18
- const vendorDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../vendor');
18
+ const vendorDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../vendor');
19
19
  const { mintCredential, discoverTokenEndpoint, createGrantSession, revokeCredentialViaAccount } =
20
20
  require(path.join(vendorDir, 'idp-grant.cjs'));
21
21
 
@@ -23,7 +23,7 @@ export { mintCredential, discoverTokenEndpoint, revokeCredentialViaAccount };
23
23
 
24
24
  // Re-exported so the callers that guard their own deletions — run-agent, the
25
25
  // server package's live test, the smoke suite — keep importing it from here.
26
- export { protectedFromDeletion } from './pod/transport.mjs';
26
+ export { protectedFromDeletion } from '../pod/transport.mjs';
27
27
 
28
28
  export class RemotePod extends PodTransport {
29
29
  constructor(credential, { log = () => {}, home = null, session = null, role = 'agent' } = {}) {
@@ -15,10 +15,10 @@ import * as $rdf from 'rdflib';
15
15
 
16
16
  import { createAccountWithPod as realCreateAccount } from './account.mjs';
17
17
  import { mintCredential as realMint } from './remote.mjs';
18
- import { hashPassword } from './mastoapi.mjs';
19
- import { webfingerHost } from './wire.mjs';
18
+ import { hashPassword } from '../client/masto/index.mjs';
19
+ import { webfingerHost } from '../core/wire.mjs';
20
20
  import { rootOf, recordLastUsed, writeJsonAtomic } from './home.mjs';
21
- import { insecureUrlReason } from './safefetch.mjs';
21
+ import { insecureUrlReason } from '../shared/safefetch.mjs';
22
22
  import { CURRENT_LAYOUT, isCurrent } from './migrate.mjs';
23
23
 
24
24
  const SOLID = $rdf.Namespace('http://www.w3.org/ns/solid/terms#');
@@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url';
13
13
 
14
14
  export const LATEST_URL = 'https://raw.githubusercontent.com/jeff-zucker/FediPod/main/package.json';
15
15
 
16
- export const repoRoot = () => path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ export const repoRoot = () => path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
17
17
 
18
18
  export const localVersion = (root = repoRoot()) => {
19
19
  try { return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version || null; }
@@ -13,7 +13,7 @@
13
13
  // whole mechanism (the test suite sets it, so throwaway agents do not fight
14
14
  // over a real machine's door).
15
15
  import https from 'node:https';
16
- import { localFetch } from './localapi.mjs';
16
+ import { localFetch } from '../client/localapi.mjs';
17
17
 
18
18
  export const DIRECTORY_PORT = 8030;
19
19
 
@@ -17,9 +17,9 @@
17
17
 
18
18
  import crypto from 'node:crypto';
19
19
  import { handleDelivery } from './gateway-core.mjs';
20
- import { readCapped, safeFetch, isLoopbackHost } from './safefetch.mjs';
21
- import * as podRoot from './pod/root.mjs';
22
- import * as podPolicy from './pod/policy.mjs';
20
+ import { readCapped, safeFetch, isLoopbackHost } from '../shared/safefetch.mjs';
21
+ import * as podRoot from '../pod/root.mjs';
22
+ import * as podPolicy from '../pod/policy.mjs';
23
23
 
24
24
  // The one WebFinger document, spelled out here rather than imported from
25
25
  // wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
@@ -14,7 +14,7 @@
14
14
 
15
15
  import crypto from 'node:crypto';
16
16
  import { verifyHttpSignature, makeSafeLoader, makeReceipt, signReceipt } from './httpsig.mjs';
17
- import * as inbox from './pod/inbox.mjs';
17
+ import * as inbox from '../pod/inbox.mjs';
18
18
 
19
19
  const DEFAULT_MAX_BYTES = 512 * 1024; // mirror intake.mjs MAX_ITEM_BYTES
20
20
 
@@ -11,7 +11,7 @@
11
11
 
12
12
  import crypto from 'node:crypto';
13
13
  import { verifyRequestDetailed } from '@fedify/fedify/sig';
14
- import { assertPublicUrl, safeFetch, readCapped } from './safefetch.mjs';
14
+ import { assertPublicUrl, safeFetch, readCapped } from '../shared/safefetch.mjs';
15
15
 
16
16
  // An SSRF-safe JSON-LD document loader for Fedify's key fetch. The keyId in a
17
17
  // delivery is chosen by the sender, so every deref it triggers is untrusted
@@ -16,15 +16,16 @@ import path from 'node:path';
16
16
 
17
17
  import { createRequire } from 'node:module';
18
18
 
19
- import { Agent } from '../run-agent.mjs';
20
- import { RemotePod } from './remote.mjs';
21
- import { apUrls } from './wire.mjs';
22
- import { writeJsonAtomic } from './home.mjs';
23
- import { buildAdminSurface } from './admin.mjs';
24
- import { FixedAuthorities } from './guard.mjs';
19
+ import { Agent } from '../../run-agent.mjs';
20
+ import { RemotePod } from '../device/remote.mjs';
21
+ import { apUrls, DEFAULT_ROOT } from '../core/wire.mjs';
22
+ import { writeJsonAtomic } from '../device/home.mjs';
23
+ import { buildAdminSurface } from '../device/admin/index.mjs';
24
+ import { FixedAuthorities } from '../shared/guard.mjs';
25
+ import { CONNECTION_PREFIX, fileVault, moveVault, podVault } from '../connections/vault.mjs';
25
26
 
26
27
  const require = createRequire(import.meta.url);
27
- const { makeGate } = require('../vendor/gate.cjs');
28
+ const { makeGate } = require('../../vendor/gate.cjs');
28
29
 
29
30
  // How long to gather store events before draining, so a delivery of several
30
31
  // items costs one sweep rather than one each.
@@ -34,23 +35,57 @@ const DRAIN_COALESCE_MS = 250;
34
35
  const mintSecret = () => crypto.randomBytes(32).toString('base64');
35
36
 
36
37
  /**
37
- * The secret guarding one identity's owner door, kept beside its signing key.
38
- * Minted when absent; `rotate` re-mints over an existing one — which is how a
39
- * lost secret is recovered, by proving pod control again. Never logged.
38
+ * The secret guarding one identity's owner door, kept in its pod's state
39
+ * beside its signing key. Minted when absent; `rotate` re-mints over an
40
+ * existing one — which is how a lost secret is recovered, by proving pod
41
+ * control again. Never logged.
42
+ *
43
+ * `dataDir`, when given, is where an identity set up before this kept it: the
44
+ * secret is moved into the pod and the host's copy removed, so the owner's
45
+ * existing door link goes on working.
40
46
  */
41
- export function ensureDoorSecret(dataDir, handle, { rotate = false } = {}) {
42
- const home = path.join(dataDir, handle);
43
- fs.mkdirSync(home, { recursive: true, mode: 0o700 });
44
- const file = path.join(home, 'door-secret.json');
47
+ export async function ensureDoorSecret(session, podBase, { rotate = false, dataDir = null, handle = null, log = () => {} } = {}) {
48
+ const base = podBase.endsWith('/') ? podBase : podBase + '/';
49
+ const url = apUrls(base, DEFAULT_ROOT).state + 'door-secret.json';
50
+ const onHost = dataDir && handle ? path.join(dataDir, handle, 'door-secret.json') : null;
51
+
45
52
  if (!rotate) {
46
- try {
47
- const rec = JSON.parse(fs.readFileSync(file, 'utf8'));
48
- if (rec?.secret) return { secret: rec.secret, path: file, rotated: false };
49
- } catch { /* absent or unreadable: mint below */ }
53
+ const res = await session.fetch(url, { headers: { accept: 'application/json' } });
54
+ if (res.ok) {
55
+ try {
56
+ const rec = JSON.parse(await res.text());
57
+ if (rec?.secret) {
58
+ if (onHost) fs.rmSync(onHost, { force: true });
59
+ return { secret: rec.secret, url, rotated: false };
60
+ }
61
+ } catch { /* unreadable: mint below */ }
62
+ }
63
+ // Set up before the secret lived on the pod: keep the one the owner has.
64
+ if (onHost && fs.existsSync(onHost)) {
65
+ try {
66
+ const rec = JSON.parse(fs.readFileSync(onHost, 'utf8'));
67
+ if (rec?.secret && await putSecret(session, url, rec)) {
68
+ fs.rmSync(onHost, { force: true });
69
+ log('door secret moved into the pod');
70
+ return { secret: rec.secret, url, rotated: false };
71
+ }
72
+ } catch { /* unreadable: mint below */ }
73
+ }
50
74
  }
75
+
51
76
  const rec = { secret: mintSecret(), mintedAt: new Date().toISOString() };
52
- writeJsonAtomic(file, rec, { mode: 0o600 });
53
- return { secret: rec.secret, path: file, rotated: rotate };
77
+ if (!await putSecret(session, url, rec)) throw new Error(`could not write the door secret to ${url}`);
78
+ if (onHost) fs.rmSync(onHost, { force: true });
79
+ return { secret: rec.secret, url, rotated: rotate };
80
+ }
81
+
82
+ async function putSecret(session, url, rec) {
83
+ const res = await session.fetch(url, {
84
+ method: 'PUT',
85
+ headers: { 'content-type': 'application/json' },
86
+ body: JSON.stringify(rec, null, 2) + '\n',
87
+ });
88
+ return res.ok;
54
89
  }
55
90
 
56
91
  /** The identity's name: a subdomain pod is its label, a path pod its last segment. */
@@ -64,20 +99,90 @@ export function handleFor(podBase) {
64
99
  * The credential file an embedded identity runs on. It names the pod and
65
100
  * nothing else: there is no client id and no secret, because there is nobody
66
101
  * to authenticate to. Absent `privateRoot`, so the state tree stays on the pod.
102
+ *
103
+ * `keysMode: 'pod'` because here the pod's server and the agent are the same
104
+ * process: keeping the signing key on the host's disk puts it somewhere the
105
+ * pod does not travel to, so handing the pod over hands over an identity that
106
+ * cannot sign. An identity set up before this gets the mode added.
67
107
  */
68
108
  function ensureCredential(home, { podBase, webId }) {
69
109
  const file = path.join(home, 'credential.json');
70
- try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
71
- catch { /* first run for this identity */ }
110
+ try {
111
+ const rec = JSON.parse(fs.readFileSync(file, 'utf8'));
112
+ if (rec.keysMode === 'pod') return rec;
113
+ const updated = { ...rec, keysMode: 'pod' };
114
+ writeJsonAtomic(file, updated, { mode: 0o600 });
115
+ return updated;
116
+ } catch { /* first run for this identity */ }
72
117
  const rec = {
73
118
  webId,
74
119
  remotePod: podBase.endsWith('/') ? podBase : podBase + '/',
75
120
  root: 'activitypods-js/',
121
+ keysMode: 'pod',
76
122
  };
77
123
  writeJsonAtomic(file, rec, { mode: 0o600 });
78
124
  return rec;
79
125
  }
80
126
 
127
+ /**
128
+ * Move an identity's signing key off the host's disk and into its pod.
129
+ *
130
+ * Only after the pod has it: the private key is the one thing here that
131
+ * nothing can rebuild, and remote servers have its public half cached, so a
132
+ * copy lost between the two places is an identity that can never sign again
133
+ * under the name it already published.
134
+ */
135
+ async function keyIntoPod(agent, home, log) {
136
+ const local = path.join(home, 'keys.json');
137
+ if (!fs.existsSync(local)) return;
138
+ if (agent.store.read('keys.json', null)) {
139
+ fs.rmSync(local, { force: true });
140
+ log('this identity signs with the key in its pod; removed the copy on this host');
141
+ return;
142
+ }
143
+ let rec;
144
+ try {
145
+ rec = JSON.parse(fs.readFileSync(local, 'utf8'));
146
+ } catch (e) {
147
+ log(`the local signing key could not be read (${e.message}) — leaving it where it is`);
148
+ return;
149
+ }
150
+ agent.store.write('keys.json', rec);
151
+ if (!await agent.store.commit()) {
152
+ log('could not write the signing key to the pod — it stays on this host for now');
153
+ return;
154
+ }
155
+ fs.rmSync(local, { force: true });
156
+ log('signing key moved into the pod');
157
+ }
158
+
159
+ /**
160
+ * Move an identity's credentials for accounts it holds on OTHER servers off
161
+ * the host's disk and into its pod. A token here is full access to somebody's
162
+ * account on a server this project does not run, so the host's copy goes only
163
+ * after the pod has taken it.
164
+ */
165
+ async function connectionsIntoPod(agent, home, log) {
166
+ const moves = [
167
+ [fileVault(path.join(home, 'fediaccts')), podVault(agent.store, `${CONNECTION_PREFIX}fedi-`)],
168
+ [fileVault(path.join(home, 'fediaccts', '_apps')), podVault(agent.store, `${CONNECTION_PREFIX}fediapp-`)],
169
+ [fileVault(home), podVault(agent.store, CONNECTION_PREFIX)],
170
+ ];
171
+ // The last pair is the whole home directory, where only the Bluesky
172
+ // credential is ours to move.
173
+ for (const [from, to] of moves.slice(0, 2)) await moveVault(from, to, log);
174
+ const bluesky = moves[2][0].read('atproto');
175
+ if (bluesky && !moves[2][1].read('bluesky')) {
176
+ moves[2][1].write('bluesky', bluesky);
177
+ if (await moves[2][1].commit()) {
178
+ await moves[2][0].remove('atproto');
179
+ log('the Bluesky connection moved into the pod');
180
+ } else {
181
+ log('could not write the Bluesky connection to the pod — it stays on this host for now');
182
+ }
183
+ }
184
+ }
185
+
81
186
  /**
82
187
  * Bring one identity up inside the server.
83
188
  *
@@ -151,6 +256,10 @@ export async function startEmbeddedAgent({
151
256
  agent.stateLoaded = true;
152
257
  }
153
258
 
259
+ // Before connect(), which is what looks the key and the connections up.
260
+ await keyIntoPod(agent, home, log);
261
+ await connectionsIntoPod(agent, home, log);
262
+
154
263
  await agent.connect();
155
264
 
156
265
  // The client surfaces, on the pod's own origin: the Mastodon API a phone app
@@ -6,4 +6,4 @@
6
6
  // importers around the project do not move, and so there is still one obvious
7
7
  // place to look for "how does this read a Link header".
8
8
 
9
- export { linkTargets, REL } from './pod/links.mjs';
9
+ export { linkTargets, REL } from '../pod/links.mjs';
@@ -16,7 +16,7 @@ import { fileURLToPath } from 'node:url';
16
16
  let version = '0';
17
17
  try {
18
18
  ({ version } = JSON.parse(fs.readFileSync(
19
- path.join(path.dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8')));
19
+ path.join(path.dirname(fileURLToPath(import.meta.url)), '../../package.json'), 'utf8')));
20
20
  } catch {
21
21
  try {
22
22
  ({ version } = JSON.parse(fs.readFileSync(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "0.19.0",
3
+ "version": "1.2.0",
4
4
  "description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,7 +26,11 @@
26
26
  "gui.md",
27
27
  "cli.md",
28
28
  "groups.md",
29
- "architecture.svg"
29
+ "architecture.svg",
30
+ "installed-agent.md",
31
+ "architecture.md",
32
+ "gateway.md",
33
+ "browser.svg"
30
34
  ],
31
35
  "scripts": {
32
36
  "start": "node bin/fedipod.mjs up",
@@ -45,10 +49,20 @@
45
49
  "dependencies": {
46
50
  "@fedify/fedify": "^2.3.4",
47
51
  "@netlify/blobs": "^10.7.13",
52
+ "@rdfjs/data-model": "^2.1.2",
53
+ "@rdfjs/dataset": "^2.0.3",
54
+ "@rdfjs/environment": "^1.0.0",
55
+ "@rdfjs/namespace": "^2.0.1",
56
+ "@rdfjs/term-map": "^2.0.2",
57
+ "@rdfjs/term-set": "^2.0.3",
48
58
  "@solid/access-token-verifier": "^2.1.1",
59
+ "clownface": "^2.0.3",
49
60
  "jose": "^6.2.8",
50
61
  "json-canon": "^1.0.1",
62
+ "jsonld": "^9.0.0",
63
+ "n3": "^2.1.1",
51
64
  "node-forge": "^1.4.0",
65
+ "rdf-validate-shacl": "^0.6.5",
52
66
  "rdflib": "^2.4.0",
53
67
  "sanitize-html": "^2.17.6",
54
68
  "undici": "^6.28.0",