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,410 @@
1
+ // setup.mjs — making an identity and its secrets: setup, rotate-key,
2
+ // revoke-credential, tokens, passwd, keys.
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { tildify, rootOf, recordLastUsed, writeJsonAtomic } from '../../home.mjs';
8
+ import { insecureUrlReason } from '../../../shared/safefetch.mjs';
9
+ import { localFetch } from '../../../client/localapi.mjs';
10
+ import { args, flag, has, PROFILE, AP_ROOT, HOME, PORT, recordAgent, useProfile, requireHandle, requireIdentity, askHidden, ask, endAsking, openBrowser, finish, IDENTITY_FLAGS, refuseExistingIdentity, runBrowserSetup } from '../context.mjs';
11
+
12
+ export async function setup() {
13
+ // --profile names the home before anything is asked, so a collision is
14
+ // knowable now; refuseExistingIdentity exits when it finds one.
15
+ if (PROFILE) { useProfile(PROFILE); refuseExistingIdentity(); }
16
+ // At a terminal with no identity flag, setup asks two things and hands the
17
+ // rest to the page.
18
+ if (process.stdin.isTTY && !has('cli') && !IDENTITY_FLAGS.some(f => args.includes('--' + f))) {
19
+ return runBrowserSetup();
20
+ }
21
+ const root = flag('root');
22
+ const kind = has('group') ? 'group' : 'person';
23
+ const approveJoins = has('group') && has('approve-joins');
24
+ const summary = flag('summary');
25
+ const icon = flag('icon');
26
+ let pod = flag('pod');
27
+ const interactive = process.stdin.isTTY;
28
+
29
+ // Everything that shapes your identity is asked for here, with defaults,
30
+ // because these are decisions — the pod name becomes half of your
31
+ // permanent address, and nobody should discover that after the fact.
32
+ // Flags skip the matching question, so scripted setup is unchanged.
33
+ let newAccount = has('new-account');
34
+ if (!newAccount && !pod) {
35
+ if (!interactive) {
36
+ console.error('need --email and --handle, plus either --pod <url> or --new-account');
37
+ process.exit(2);
38
+ }
39
+ const have = await ask('do you already have a Solid pod? (y/n)', 'n');
40
+ if (/^y/i.test(have)) {
41
+ // Either tuck the fediverse account into the pod they already have,
42
+ // or make a fresh pod on the same Solid account for it.
43
+ const where = await ask('store your Fediverse account in that pod, or in a new pod? (existing/new)', 'existing');
44
+ if (/^n/i.test(where)) newAccount = true;
45
+ else pod = await ask('your pod address (e.g. https://you.solidcommunity.net/)');
46
+ } else {
47
+ newAccount = true;
48
+ }
49
+ }
50
+ if (!newAccount && !pod) { console.error('no pod given'); process.exit(2); }
51
+
52
+ const issuer = flag('issuer') || await ask('Solid identity provider', 'https://solidcommunity.net');
53
+ // Before the password is asked for, let alone sent. The issuer is where it
54
+ // goes and the pod is where the credential it buys is used, so an http:
55
+ // address off this machine puts both in clear. Loopback is exempt: it never
56
+ // reaches a network interface, and a pod on this machine is an ordinary way
57
+ // to run this.
58
+ for (const [url, what] of [[issuer, 'identity provider address'], [pod, 'pod address']]) {
59
+ const bad = insecureUrlReason(url, what);
60
+ if (bad) { console.error(bad); process.exit(2); }
61
+ }
62
+ const email = flag('email') || await ask(`account email at ${new URL(issuer).host}`);
63
+ const handle = flag('handle') || await ask('handle (the name in your address; permanent)');
64
+ if (!email || !handle) {
65
+ console.error('an email and a handle are required');
66
+ process.exit(2);
67
+ }
68
+ // Before anything irreversible: the account creation and the mint are both
69
+ // one-way, and a credential is shown once. A home that cannot be decided has
70
+ // to be refused here, not after there is something to lose.
71
+ requireHandle(handle);
72
+ useProfile(PROFILE || handle);
73
+ refuseExistingIdentity();
74
+ const podName = newAccount
75
+ ? (flag('pod-name') || await ask('pod name (this becomes the domain of your address)', handle))
76
+ : null;
77
+ const name = flag('name') || await ask('display name (shown above your address)', handle);
78
+
79
+ // A handle resolves through <host>/.well-known/webfinger, so it only works
80
+ // when the pod owns the root of its host. Whether a NEW pod gets its own
81
+ // subdomain is the server's call, so promise nothing here we cannot keep.
82
+ const { webfingerHost } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
83
+ const issuerHost = new URL(issuer).host;
84
+ const wfHost = newAccount ? null : webfingerHost(pod);
85
+ // A person warned about an unresolvable handle is the one who suffers, so a
86
+ // warning is their call to accept. Nobody could ever find this group, and the
87
+ // people it would fail are not the operator reading the warning.
88
+ if (kind === 'group' && !newAccount && !wfHost) {
89
+ console.error(`${pod} is a path on ${new URL(pod).host}, not the root of its own host.`);
90
+ console.error('WebFinger is answered only at a host root, so nobody could find this group.');
91
+ console.error('Give the group a pod of its own: fedipod setup --group --new-account');
92
+ process.exit(2);
93
+ }
94
+ console.log(kind === 'group' ? '\nThe group will be:\n' : '\nYou will be:\n');
95
+ console.log(` ${name}`);
96
+ if (wfHost) {
97
+ console.log(` @${handle}@${wfHost}\n`);
98
+ } else if (newAccount) {
99
+ console.log(` @${handle}@${podName}.${issuerHost}\n`);
100
+ console.log(`— provided ${issuerHost} gives each pod its own subdomain. Some servers put`);
101
+ console.log(`pods at ${issuerHost}/${podName}/ instead, and a pod sharing a host cannot`);
102
+ console.log('answer WebFinger for an address. Setup checks which you got and says so');
103
+ console.log('before publishing anything.\n');
104
+ } else {
105
+ console.log(` @${handle}@${new URL(pod).host} — WILL NOT RESOLVE\n`);
106
+ console.log(`This pod is ${pod} — a path on ${new URL(pod).host}, not the root of its own`);
107
+ console.log('host. WebFinger is answered only at a host root, which this pod cannot');
108
+ console.log('write to, so other servers will not find you. Posting and reading still');
109
+ console.log('work; being discovered does not.\n');
110
+ }
111
+ console.log('The display name can be changed later; the handle and pod cannot.');
112
+ const go = await ask(newAccount
113
+ ? (kind === 'group' ? 'create pod and group? (y/n)' : 'create pod and Fediverse account? (y/n)')
114
+ : (kind === 'group' ? 'create group on this pod? (y/n)' : 'create Fediverse account on this pod? (y/n)'), 'y');
115
+ if (!/^y/i.test(go)) { console.log('nothing was created'); process.exit(0); }
116
+ endAsking(); // hand the tty to the password prompt
117
+
118
+ const password = process.env.AP_PASSWORD || await askHidden(`password for ${email} at ${issuer}: `);
119
+
120
+ if (newAccount) {
121
+ const { createAccountWithPod } = await import(new URL('../../../../lib/device/account.mjs', import.meta.url));
122
+ const made = await createAccountWithPod({ issuer, email, password, podName });
123
+ pod = made.pod;
124
+ console.log(`account + pod created: ${pod}`);
125
+ if (!webfingerHost(pod)) {
126
+ console.log(`\n${issuerHost} created the pod at a path rather than on its own subdomain,`);
127
+ console.log(`so @${handle}@\u2026 cannot be discovered by other Fediverse servers.`);
128
+ const cont = kind === 'group' ? 'n' : (interactive ? await ask('continue anyway? (y/n)', 'n') : 'y');
129
+ endAsking();
130
+ if (!/^y/i.test(cont)) {
131
+ console.log('stopping \u2014 the pod exists, but no actor was published');
132
+ process.exit(0);
133
+ }
134
+ }
135
+ }
136
+
137
+ const { mintCredential } = await import(new URL('../../../../lib/device/remote.mjs', import.meta.url));
138
+ // New credentials only: one already registered keeps the name it was minted
139
+ // under, so existing pods stay as they are unless they are set up again.
140
+ const credential = await mintCredential({ origin: issuer, email, password, name: 'fedipod' });
141
+ const rec = {
142
+ ...credential,
143
+ remotePod: pod.endsWith('/') ? pod : pod + '/',
144
+ // The private half goes on THIS machine, exactly as the browser setup does
145
+ // (lib/setup.mjs). Omitting it here meant the two paths produced different
146
+ // installs from the same answers: the CLI put the timeline, contacts,
147
+ // blocklist and notifications on the pod — the layout the relay design
148
+ // exists to avoid, and the one that makes receiving a post cost pod writes.
149
+ // It also made provisioning write a tree it did not need, which is where a
150
+ // slow server showed it up.
151
+ privateRoot: flag('private-root')
152
+ || pathToFileURL(path.join(HOME, 'private')).href + '/',
153
+ ...(root ? { root } : {}),
154
+ ...(flag('keys') === 'pod' ? { keysMode: 'pod' } : {}),
155
+ ...(has('rotate-key') ? { rotateKeyOnce: true } : {}),
156
+ // What shape this install is. Both setup paths stamp it, so `upgrade` can
157
+ // tell an old install from a new one rather than inferring it.
158
+ layout: (await import(new URL('../../../../lib/device/migrate.mjs', import.meta.url))).CURRENT_LAYOUT,
159
+ };
160
+ fs.mkdirSync(HOME, { recursive: true, mode: 0o700 });
161
+ writeJsonAtomic(path.join(HOME, 'credential.json'), rec);
162
+ // The one you just made is the one you are using.
163
+ if (rootOf(HOME) === AP_ROOT) recordLastUsed(AP_ROOT, path.basename(HOME));
164
+ recordAgent({ port: PORT, handle }); // later commands need no --port
165
+ console.log(`credential minted and saved to ${path.join(HOME, 'credential.json')}`);
166
+
167
+ const { Agent } = await import(new URL('../../../../run-agent.mjs', import.meta.url));
168
+ const agent = new Agent({ home: HOME, log: (...a) => console.log('[setup]', ...a) });
169
+ await agent.bootstrap({ handle, name, root, kind, approveJoins, summary, icon });
170
+ await agent.connect({ repair: false }); // publishProfile below is the publish
171
+ await agent.publisher.publishProfile();
172
+ await agent.store.flush();
173
+ const finalHost = webfingerHost(rec.remotePod);
174
+ const what = kind === 'group' ? 'group' : 'actor';
175
+ console.log(finalHost
176
+ ? `${what} published: @${handle}@${finalHost}`
177
+ : `${what} published, but not reachable as a handle \u2014 ${rec.remotePod} is not a host root`);
178
+
179
+ // Straight into serving — setup ends with a working client in the browser.
180
+ const { startAdmin } = await import(new URL('../../../../lib/device/admin/index.mjs', import.meta.url));
181
+ const { hostLabel } = await import(new URL('../../../../lib/shared/guard.mjs', import.meta.url));
182
+ startAdmin({ port: PORT, handle, gateToken: process.env.AP_GATE_TOKEN || '', agent, log: (...a) => console.log('[ap]', ...a) });
183
+ const shutdown = () => {
184
+ setTimeout(() => process.exit(0), 1500).unref(); // never hang a stop on a slow pod
185
+ try { fs.rmSync(path.join(HOME, 'agent.pid'), { force: true }); } catch {}
186
+ Promise.allSettled([agent.store.flush(), agent.lease?.release()]).finally(() => process.exit(0));
187
+ };
188
+ process.on('SIGINT', shutdown);
189
+ process.on('SIGTERM', shutdown);
190
+ // The named origin, and the instance to log the client into is that same
191
+ // origin. Opening the shared one and then telling you to log in there is how
192
+ // two identities end up sharing a browser storage bucket, and a client on the
193
+ // shared origin shows whichever account it happens to hold.
194
+ const label = hostLabel(handle);
195
+ const authority = `${label ? label + '.' : ''}localhost:${PORT}`;
196
+ const url = `http://${authority}/`;
197
+ if (kind === 'group') {
198
+ console.log(`group running on ${url} — see \`fedipod members\``);
199
+ } else {
200
+ console.log(`agent running — opening ${url} (log in with instance ${authority})`);
201
+ openBrowser(url);
202
+ }
203
+ }
204
+
205
+ export async function rotateKey() {
206
+ requireIdentity();
207
+ const { Agent } = await import(new URL('../../../../run-agent.mjs', import.meta.url));
208
+ const agent = new Agent({ home: HOME, log: (...a) => console.log('[rotate-key]', ...a) });
209
+ // A home whose keys.json is gone or truncated cannot connect() at all:
210
+ // resolveKeys refuses to mint over a key the actor already publishes, which
211
+ // is the right default — minting silently would invalidate every signature
212
+ // the other device can still make. It did leave the advice that refusal
213
+ // prints ("run fedipod rotate-key") a dead end, though, because this
214
+ // command connects first and meets the same refusal. --force arms the
215
+ // one-shot rotation in the credential so the connect can get past it.
216
+ const forced = has('force');
217
+ const rotateCredPath = path.join(HOME, 'credential.json');
218
+ if (forced) {
219
+ const cred = JSON.parse(fs.readFileSync(rotateCredPath, 'utf8'));
220
+ writeJsonAtomic(rotateCredPath, { ...cred, rotateKeyOnce: true });
221
+ console.log('--force: the replacement key is minted as the agent connects\n');
222
+ }
223
+ // Read-only until you say yes: connecting for real acquires the lease and
224
+ // starts the whole active agent — a destructive inbox drain, a channel
225
+ // subscription, ACL probes and a tag-feed sweep — before the prompt.
226
+ if (!await agent.connect({ act: false })) {
227
+ console.error('nothing to rotate — no configured agent in this AP_HOME');
228
+ process.exit(2);
229
+ }
230
+ const cfg = agent.store.getConfig();
231
+ console.log(`\nRotating the signing key for @${cfg.handle}@${new URL(cfg.remotePod).host}\n`);
232
+ console.log(' · a new keypair replaces the one in this home');
233
+ console.log(' · the actor document is republished so other servers learn it');
234
+ console.log(' · ANY OTHER DEVICE holding the old key stops being able to sign\n');
235
+ const ans = has('yes') ? 'y' : await ask('rotate now? (y/n)', 'n');
236
+ endAsking();
237
+ if (!/^y/i.test(ans)) {
238
+ // --force arms the one-shot rotation before the prompt, so the preview
239
+ // connect can read past the mint refusal. Declining has to disarm it, or
240
+ // "key unchanged" would be a lie: the next ordinary start would rotate,
241
+ // and nothing on that path republishes the actor.
242
+ if (forced) {
243
+ try {
244
+ const { rotateKeyOnce, ...rest } = JSON.parse(fs.readFileSync(rotateCredPath, 'utf8'));
245
+ writeJsonAtomic(rotateCredPath, rest);
246
+ } catch { /* the credential moved under us; the flag goes with it */ }
247
+ }
248
+ console.log('key unchanged');
249
+ process.exit(0);
250
+ }
251
+ await agent.connect(); // now it may act
252
+ if (forced) {
253
+ // connect() has already minted it; all that is left is telling the
254
+ // fediverse. Calling rotateKey here would mint a second one for nothing.
255
+ await agent.publisher.publishProfile();
256
+ console.log('rotated and republished');
257
+ } else {
258
+ const r = await agent.rotateKey();
259
+ console.log(r.changed ? 'rotated and republished' : 'no change — the key was already fresh');
260
+ }
261
+ await finish(agent);
262
+ }
263
+
264
+ export async function revokeCredential() {
265
+ // The credential file cannot be protected from anything running as you —
266
+ // so the answer to a suspected leak is to kill it server-side, fast.
267
+ const { Agent } = await import(new URL('../../../../run-agent.mjs', import.meta.url));
268
+ const { revokeCredentialViaAccount } = await import(new URL('../../../../lib/device/remote.mjs', import.meta.url));
269
+ const agent = new Agent({ home: HOME, log: () => {} });
270
+ const cred = agent.readCredential();
271
+ if (!cred) { console.error('no credential to revoke'); process.exit(2); }
272
+ const email = flag('email');
273
+ if (!email) { console.error('need --email <account email> (the account password is prompted)'); process.exit(2); }
274
+ const password = process.env.AP_PASSWORD || await askHidden(`password for ${email} at ${cred.issuerOrigin}: `);
275
+ const ok = await revokeCredentialViaAccount({
276
+ origin: cred.issuerOrigin, email, password, resource: cred.resource,
277
+ }).catch(e => { console.error(`revoke failed: ${e.message}`); return false; });
278
+ if (ok) {
279
+ fs.rmSync(path.join(HOME, 'credential.json'), { force: true });
280
+ console.log('credential revoked server-side and deleted locally — run setup again to reconnect');
281
+ } else {
282
+ console.error('server did not confirm revocation — revoke it from the account dashboard');
283
+ console.error(`(credential resource: ${cred.resource || 'unknown — dashboard only'})`);
284
+ process.exit(1);
285
+ }
286
+ }
287
+
288
+ export async function tokens() {
289
+ const { Agent } = await import(new URL('../../../../run-agent.mjs', import.meta.url));
290
+ const { RemotePod } = await import(new URL('../../../../lib/device/remote.mjs', import.meta.url));
291
+ const { apUrls } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
292
+ const agent = new Agent({ home: HOME, log: () => {} });
293
+ const cred = agent.readCredential();
294
+ if (!cred) { console.error('no credential — run setup first'); process.exit(2); }
295
+ agent.remote = new RemotePod(cred);
296
+ await agent.remote.warmup();
297
+ agent.urls = apUrls(cred.remotePod, cred.root);
298
+ agent.store.attach(agent.privateStorage(cred, 'state')); // honours privateRoot
299
+ await agent.store.load();
300
+ const recs = agent.store.read('masto-tokens.json', [])
301
+ .map(r => (typeof r === 'string' ? { token: r, createdAt: null } : r));
302
+ if (has('revoke-all')) {
303
+ agent.store.write('masto-tokens.json', []);
304
+ await agent.store.flush();
305
+ console.log(`revoked ${recs.length} token(s) — every logged-in client must log in again`);
306
+ } else if (flag('revoke')) {
307
+ const prefix = flag('revoke');
308
+ const kept = recs.filter(r => !r.token.startsWith(prefix));
309
+ agent.store.write('masto-tokens.json', kept);
310
+ await agent.store.flush();
311
+ console.log(`revoked ${recs.length - kept.length} token(s) matching "${prefix}"`);
312
+ } else {
313
+ if (!recs.length) console.log('no client tokens issued');
314
+ for (const r of recs) {
315
+ const age = r.createdAt ? `${Math.round((Date.now() - r.createdAt) / 86400000)}d old` : 'undated';
316
+ console.log(`${r.token.slice(0, 8)}… ${age}`);
317
+ }
318
+ console.log('\nrevoke with: fedipod tokens --revoke <prefix> (or --revoke-all)');
319
+ }
320
+ }
321
+
322
+ export async function passwd() {
323
+ requireIdentity();
324
+ const { Agent } = await import(new URL('../../../../run-agent.mjs', import.meta.url));
325
+ const { hashPassword } = await import(new URL('../../../../lib/client/masto/index.mjs', import.meta.url));
326
+ const { RemotePod } = await import(new URL('../../../../lib/device/remote.mjs', import.meta.url));
327
+ const { apUrls } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
328
+ const agent = new Agent({ home: HOME, log: () => {} });
329
+ const cred = agent.readCredential();
330
+ if (!cred) { console.error('no credential — run setup first'); process.exit(2); }
331
+ const pw = await askHidden('new UI password: ');
332
+ if (!pw) { console.error('empty password — aborted'); process.exit(2); }
333
+ agent.remote = new RemotePod(cred);
334
+ await agent.remote.warmup();
335
+ agent.urls = apUrls(cred.remotePod, cred.root);
336
+ agent.store.attach(agent.privateStorage(cred, 'state')); // honours privateRoot
337
+ await agent.store.load();
338
+ const config = agent.store.getConfig();
339
+ if (!config) { console.error('pod state empty — run setup first'); process.exit(2); }
340
+ agent.store.setConfig({ ...config, uiPassword: hashPassword(pw) });
341
+ await agent.store.flush();
342
+ console.log('UI password set — /oauth/authorize now shows a login form (restart a running agent to pick it up)');
343
+ }
344
+
345
+ export async function keys() {
346
+ // Where the signing key lives; `--to pod` moves it into pod state — with
347
+ // the state store on a pod every device reaches, that is what lets them
348
+ // all sign as one actor. `--to local` flips back: the next start adopts
349
+ // the pod copy onto this machine and removes it from the pod.
350
+ requireIdentity();
351
+ const localKeyFile = path.join(HOME, 'keys.json');
352
+ const to = flag('to');
353
+ if (!to) {
354
+ const cred0 = JSON.parse(fs.readFileSync(path.join(HOME, 'credential.json'), 'utf8'));
355
+ console.log(fs.existsSync(localKeyFile)
356
+ ? `signing key: ${tildify(localKeyFile)} (this machine only)`
357
+ : cred0.keysMode === 'pod'
358
+ ? 'signing key: pod state (shared by every device that reaches the state store)'
359
+ : 'signing key: none found here — it mints on the next start');
360
+ process.exit(0);
361
+ }
362
+ if (!['pod', 'local'].includes(to)) {
363
+ console.error('usage: fedipod keys [--to pod|local]');
364
+ process.exit(2);
365
+ }
366
+ // The running agent holds the key and the config in memory — stop it first.
367
+ const answering = await localFetch(HOME, PORT, `/status`).then(r => r.ok).catch(() => false);
368
+ if (answering) {
369
+ console.error(`an agent is answering on :${PORT} — stop it first (fedipod stop), then re-run`);
370
+ process.exit(2);
371
+ }
372
+ const credPath = path.join(HOME, 'credential.json');
373
+ const cred = JSON.parse(fs.readFileSync(credPath, 'utf8'));
374
+ if (to === 'local') {
375
+ if (cred.keysMode !== 'pod') { console.log('keys are already local'); process.exit(0); }
376
+ delete cred.keysMode;
377
+ writeJsonAtomic(credPath, cred);
378
+ console.log('keys set to local — the next start adopts the pod copy onto this machine and removes it from the pod');
379
+ process.exit(0);
380
+ }
381
+ if (cred.keysMode === 'pod' && !fs.existsSync(localKeyFile)) {
382
+ console.log('keys are already pod-held');
383
+ process.exit(0);
384
+ }
385
+ const { Agent } = await import(new URL('../../../../run-agent.mjs', import.meta.url));
386
+ const { RemotePod } = await import(new URL('../../../../lib/device/remote.mjs', import.meta.url));
387
+ const { apUrls } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
388
+ const { moveKeysToPod } = await import(new URL('../../../../lib/core/keys.mjs', import.meta.url));
389
+ const agent = new Agent({ home: HOME, log: () => {} });
390
+ agent.remote = new RemotePod(cred);
391
+ await agent.remote.warmup();
392
+ agent.store.attach(agent.privateStorage(cred, 'state'));
393
+ await agent.store.load();
394
+ const gwActor = agent.store.getConfig()?.gateway?.frontActor;
395
+ const urls = apUrls(cred.remotePod, cred.root,
396
+ gwActor ? { publicBase: gwActor.replace(/ap\/actor\/?$/, '') } : undefined);
397
+ try {
398
+ await moveKeysToPod(agent.store, { localDir: HOME, actorId: urls.actor, log: console.log });
399
+ } catch (e) {
400
+ console.error(e.message);
401
+ process.exit(1);
402
+ }
403
+ cred.keysMode = 'pod';
404
+ writeJsonAtomic(credPath, cred);
405
+ if (cred.privateRoot) {
406
+ console.log(`note: this identity's state store is ${cred.privateRoot} — devices share the key`);
407
+ console.log('only when the state store is a pod they can all reach (`fedipod state --to pod`).');
408
+ }
409
+ console.log('done — restart the agent');
410
+ }