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,405 @@
1
+ // embed.mjs — the agent, running inside the pod server that holds its pod.
2
+ //
3
+ // Nothing here reimplements the agent: it is the same Agent class, the same
4
+ // intake, deliverer, publisher and lease. What changes is the transport. The
5
+ // caller hands in a session that reaches the pod through the server's own
6
+ // store, and RemotePod takes it instead of minting a credential — so an
7
+ // identity acts on its pod with no token, no socket, and no second process.
8
+ //
9
+ // The one thing a pod server can do that a laptop cannot is notice a write the
10
+ // moment it happens, so the notification socket is replaced by the store's own
11
+ // change events.
12
+
13
+ import crypto from 'node:crypto';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ import { createRequire } from 'node:module';
18
+
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 { handleDelivery } from '../gateway/gateway-core.mjs';
23
+ import { writeJsonAtomic } from '../device/home.mjs';
24
+ import { buildAdminSurface } from '../device/admin/index.mjs';
25
+ import { FixedAuthorities } from '../shared/guard.mjs';
26
+ import { CONNECTION_PREFIX, fileVault, moveVault, podVault } from '../connections/vault.mjs';
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const { makeGate } = require('../../vendor/gate.cjs');
30
+
31
+ // How long to gather store events before draining, so a delivery of several
32
+ // items costs one sweep rather than one each.
33
+ const DRAIN_COALESCE_MS = 250;
34
+
35
+ // How every secret in this project is minted (the attach flow's recipe).
36
+ const mintSecret = () => crypto.randomBytes(32).toString('base64');
37
+
38
+ /**
39
+ * The secret guarding one identity's owner door, kept in its pod's state
40
+ * beside its signing key. Minted when absent; `rotate` re-mints over an
41
+ * existing one — which is how a lost secret is recovered, by proving pod
42
+ * control again. Never logged.
43
+ *
44
+ * `dataDir`, when given, is where an identity set up before this kept it: the
45
+ * secret is moved into the pod and the host's copy removed, so the owner's
46
+ * existing door link goes on working.
47
+ */
48
+ export async function ensureDoorSecret(session, podBase, { rotate = false, dataDir = null, handle = null, log = () => {} } = {}) {
49
+ const base = podBase.endsWith('/') ? podBase : podBase + '/';
50
+ const url = apUrls(base, DEFAULT_ROOT).state + 'door-secret.json';
51
+ const onHost = dataDir && handle ? path.join(dataDir, handle, 'door-secret.json') : null;
52
+
53
+ if (!rotate) {
54
+ const res = await session.fetch(url, { headers: { accept: 'application/json' } })
55
+ .catch(() => ({ ok: false }));
56
+ if (res.ok) {
57
+ try {
58
+ const rec = JSON.parse(await res.text());
59
+ if (rec?.secret) {
60
+ if (onHost) fs.rmSync(onHost, { force: true });
61
+ return { secret: rec.secret, url, rotated: false };
62
+ }
63
+ } catch { /* unreadable: mint below */ }
64
+ }
65
+ // Set up before the secret lived on the pod: keep the one the owner has.
66
+ if (onHost && fs.existsSync(onHost)) {
67
+ try {
68
+ const rec = JSON.parse(fs.readFileSync(onHost, 'utf8'));
69
+ if (rec?.secret && await putSecret(session, url, rec)) {
70
+ fs.rmSync(onHost, { force: true });
71
+ log('door secret moved into the pod');
72
+ return { secret: rec.secret, url, rotated: false };
73
+ }
74
+ } catch { /* unreadable: mint below */ }
75
+ }
76
+ }
77
+
78
+ const rec = { secret: mintSecret(), mintedAt: new Date().toISOString() };
79
+ if (await putSecret(session, url, rec)) {
80
+ if (onHost) fs.rmSync(onHost, { force: true });
81
+ return { secret: rec.secret, url, rotated: rotate };
82
+ }
83
+ // The pod would not take it. Opting in must not fail for that: a pod that
84
+ // cannot be written to right now is a pod whose owner still asked for an
85
+ // identity, and the secret has somewhere else to live — where it lived
86
+ // before. The next start moves it onto the pod.
87
+ if (onHost) {
88
+ writeJsonAtomic(onHost, rec, { mode: 0o600 });
89
+ log(`the pod would not take the door secret; it is on this host at ${onHost} for now`);
90
+ return { secret: rec.secret, url: onHost, rotated: rotate };
91
+ }
92
+ throw new Error(`could not write the door secret to ${url}, and there is no directory to keep it in`);
93
+ }
94
+
95
+ async function putSecret(session, url, rec) {
96
+ try {
97
+ const res = await session.fetch(url, {
98
+ method: 'PUT',
99
+ headers: { 'content-type': 'application/json' },
100
+ body: JSON.stringify(rec, null, 2) + '\n',
101
+ });
102
+ return res.ok;
103
+ } catch {
104
+ // A store that throws rather than answering is a store that did not take
105
+ // it, which is the same answer.
106
+ return false;
107
+ }
108
+ }
109
+
110
+ /** The identity's name: a subdomain pod is its label, a path pod its last segment. */
111
+ export function handleFor(podBase) {
112
+ const u = new URL(podBase);
113
+ const segments = u.pathname.split('/').filter(Boolean);
114
+ return segments.length ? segments[segments.length - 1] : u.hostname.split('.')[0];
115
+ }
116
+
117
+ /**
118
+ * The credential file an embedded identity runs on. It names the pod and
119
+ * nothing else: there is no client id and no secret, because there is nobody
120
+ * to authenticate to. Absent `privateRoot`, so the state tree stays on the pod.
121
+ *
122
+ * `keysMode: 'pod'` because here the pod's server and the agent are the same
123
+ * process: keeping the signing key on the host's disk puts it somewhere the
124
+ * pod does not travel to, so handing the pod over hands over an identity that
125
+ * cannot sign. An identity set up before this gets the mode added.
126
+ */
127
+ function ensureCredential(home, { podBase, webId }) {
128
+ const file = path.join(home, 'credential.json');
129
+ try {
130
+ const rec = JSON.parse(fs.readFileSync(file, 'utf8'));
131
+ if (rec.keysMode === 'pod') return rec;
132
+ const updated = { ...rec, keysMode: 'pod' };
133
+ writeJsonAtomic(file, updated, { mode: 0o600 });
134
+ return updated;
135
+ } catch { /* first run for this identity */ }
136
+ const rec = {
137
+ webId,
138
+ remotePod: podBase.endsWith('/') ? podBase : podBase + '/',
139
+ root: 'activitypods-js/',
140
+ keysMode: 'pod',
141
+ };
142
+ writeJsonAtomic(file, rec, { mode: 0o600 });
143
+ return rec;
144
+ }
145
+
146
+ /**
147
+ * Move an identity's signing key off the host's disk and into its pod.
148
+ *
149
+ * Only after the pod has it: the private key is the one thing here that
150
+ * nothing can rebuild, and remote servers have its public half cached, so a
151
+ * copy lost between the two places is an identity that can never sign again
152
+ * under the name it already published.
153
+ */
154
+ async function keyIntoPod(agent, home, log) {
155
+ const local = path.join(home, 'keys.json');
156
+ if (!fs.existsSync(local)) return;
157
+ if (agent.store.read('keys.json', null)) {
158
+ fs.rmSync(local, { force: true });
159
+ log('this identity signs with the key in its pod; removed the copy on this host');
160
+ return;
161
+ }
162
+ let rec;
163
+ try {
164
+ rec = JSON.parse(fs.readFileSync(local, 'utf8'));
165
+ } catch (e) {
166
+ log(`the local signing key could not be read (${e.message}) — leaving it where it is`);
167
+ return;
168
+ }
169
+ agent.store.write('keys.json', rec);
170
+ if (!await agent.store.commit()) {
171
+ log('could not write the signing key to the pod — it stays on this host for now');
172
+ return;
173
+ }
174
+ fs.rmSync(local, { force: true });
175
+ log('signing key moved into the pod');
176
+ }
177
+
178
+ /**
179
+ * Move an identity's credentials for accounts it holds on OTHER servers off
180
+ * the host's disk and into its pod. A token here is full access to somebody's
181
+ * account on a server this project does not run, so the host's copy goes only
182
+ * after the pod has taken it.
183
+ */
184
+ async function connectionsIntoPod(agent, home, log) {
185
+ const moves = [
186
+ [fileVault(path.join(home, 'fediaccts')), podVault(agent.store, `${CONNECTION_PREFIX}fedi-`)],
187
+ [fileVault(path.join(home, 'fediaccts', '_apps')), podVault(agent.store, `${CONNECTION_PREFIX}fediapp-`)],
188
+ [fileVault(home), podVault(agent.store, CONNECTION_PREFIX)],
189
+ ];
190
+ // The last pair is the whole home directory, where only the Bluesky
191
+ // credential is ours to move.
192
+ for (const [from, to] of moves.slice(0, 2)) await moveVault(from, to, log);
193
+ const bluesky = moves[2][0].read('atproto');
194
+ if (bluesky && !moves[2][1].read('bluesky')) {
195
+ moves[2][1].write('bluesky', bluesky);
196
+ if (await moves[2][1].commit()) {
197
+ await moves[2][0].remove('atproto');
198
+ log('the Bluesky connection moved into the pod');
199
+ } else {
200
+ log('could not write the Bluesky connection to the pod — it stays on this host for now');
201
+ }
202
+ }
203
+ }
204
+
205
+ /**
206
+ * The pod's own inbox is a verifying door here.
207
+ *
208
+ * The server that stores the inbox is the server the delivery arrives at, so
209
+ * the signature is checked while the headers still exist and the receipt is
210
+ * written beside the activity — the same door code a standalone gateway runs,
211
+ * with nothing renamed and nothing advertised differently. The identity's
212
+ * config names its own inbox as the door and carries the receipt secret, which
213
+ * is what makes the drain read receipts at all; `trust` because a verified
214
+ * sender is one the identity may act for.
215
+ *
216
+ * An identity attached to an outside door keeps that door: only the secret
217
+ * is ensured, so receipts from either door verify against the one value.
218
+ */
219
+ export async function ensureInboxDoor(agent, urls, log = () => {}) {
220
+ const cfg = agent.store.getConfig();
221
+ if (!cfg) return;
222
+ const g = { ...(cfg.gateway || {}) };
223
+ const fresh = !g.url || !g.mode || g.mode === 'off';
224
+ if (fresh) Object.assign(g, { url: urls.inbox, mode: 'trust' });
225
+ if (!g.hmacSecret) g.hmacSecret = crypto.randomBytes(32).toString('base64');
226
+ if (fresh || !cfg.gateway?.hmacSecret) {
227
+ agent.store.setConfig({ ...cfg, gateway: g });
228
+ await agent.store.flush();
229
+ log(fresh ? 'the pod inbox verifies deliveries at the door' : 'receipt secret added for the inbox door');
230
+ }
231
+ }
232
+
233
+ /**
234
+ * One delivery to a running identity's inbox, verified at the door.
235
+ *
236
+ * `request` is the WHATWG form of the POST. `podPut` writes through the
237
+ * server's store. Returns { status, reason } for the caller to answer with.
238
+ * The policy is read from the identity's live state rather than its
239
+ * published policy document, because both are in this process.
240
+ */
241
+ export async function deliverToInbox(agent, request, { podPut, gatewayWebId = null, fetchImpl = fetch } = {}) {
242
+ const cfg = agent.store.getConfig() || {};
243
+ const contacts = agent.store.getContacts();
244
+ const bl = agent.store.getBlocklist();
245
+ const u = agent.urls;
246
+ const toPod = (x) => (u.toPod ? u.toPod(x) : x);
247
+ const ident = {
248
+ inboxUrl: toPod(u.inbox),
249
+ actorUrl: u.actor,
250
+ followersUrl: u.followers,
251
+ notesPrefix: u.notes,
252
+ following: contacts.following.filter((f) => f.accepted && !f.bsky).map((f) => f.actor),
253
+ blocklist: { domains: bl.domains || [], actors: bl.actors || [] },
254
+ kind: cfg.kind || 'person',
255
+ gatewayWebId,
256
+ hmacSecret: cfg.gateway?.hmacSecret || null,
257
+ };
258
+ return handleDelivery(request, ident, { podPut, fetchImpl });
259
+ }
260
+
261
+ /**
262
+ * Bring one identity up inside the server.
263
+ *
264
+ * `session` is the store-backed transport ({ fetch, warmup, stats }).
265
+ * `resourceStore` is optional and used only to watch for inbox writes.
266
+ * Returns the running agent and a stop() that leaves the pod tidy.
267
+ */
268
+ export async function startEmbeddedAgent({
269
+ podBase,
270
+ dataDir,
271
+ session,
272
+ resourceStore = null,
273
+ webIdSuffix = 'profile/card#me',
274
+ log = () => {},
275
+ pollSeconds = null,
276
+ autoAcceptFollows = true,
277
+ gateToken = null,
278
+ uiPath = '/fedipod/',
279
+ }) {
280
+ const base = podBase.endsWith('/') ? podBase : podBase + '/';
281
+ const handle = handleFor(base);
282
+ const home = path.join(dataDir, handle);
283
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
284
+
285
+ const webId = base + webIdSuffix.replace(/^\//u, '');
286
+ const cred = ensureCredential(home, { podBase: base, webId });
287
+
288
+ const agent = new Agent({ home, log });
289
+ agent.log = log;
290
+ agent.store.log = log;
291
+ // Read by connect() when it builds Intake: in-process, the pod's own change
292
+ // events are the push channel.
293
+ agent.embedded = true;
294
+ if (pollSeconds) agent.pollSeconds = pollSeconds;
295
+ agent.remote = new RemotePod(cred, { log, session });
296
+ await agent.remote.warmup();
297
+
298
+ // Whether this pod already carries an identity is the question connect()
299
+ // assumes an answer to, so settle it first.
300
+ const urls = apUrls(cred.remotePod, cred.root);
301
+ agent.store.attach(agent.privateStorage(cred, 'state', urls));
302
+
303
+ // A state container that is NOT THERE means a pod nobody has set up yet. A
304
+ // state container we could not READ means a pod we could not ask, which is a
305
+ // different answer: treating it as empty would provision a second identity
306
+ // over the top of a working one.
307
+ const state = await agent.remote.fetch(urls.state, { headers: { accept: 'text/turtle' } });
308
+ const unprovisioned = state.status === 404 || state.status === 410;
309
+ if (!unprovisioned && state.status >= 400) {
310
+ throw new Error(`state at ${urls.state} → ${state.status} — cannot tell whether this pod is set up`);
311
+ }
312
+ if (!unprovisioned) {
313
+ await agent.store.load();
314
+ agent.stateLoaded = true;
315
+ }
316
+
317
+ if (!agent.store.getConfig()) {
318
+ // The pod itself must exist first. A server creates its seeded pods after
319
+ // it runs its initializers, so an agent that provisioned eagerly would
320
+ // leave a container tree exactly where a pod is about to be created.
321
+ const profile = await agent.remote.fetch(webId.split('#')[0], { headers: { accept: 'text/turtle' } });
322
+ if (profile.status === 404 || profile.status === 410) {
323
+ throw new Error(`no pod at ${base} yet — its owner profile is not there`);
324
+ }
325
+ log(`no identity on ${base} yet — provisioning @${handle}`);
326
+ await agent.bootstrap({ handle, name: handle, kind: 'person' });
327
+ if (autoAcceptFollows) {
328
+ agent.store.setConfig({ ...agent.store.getConfig(), autoAcceptFollows: true });
329
+ await agent.store.flush();
330
+ }
331
+ agent.stateLoaded = true;
332
+ }
333
+
334
+ // Before connect(), which is what looks the key and the connections up.
335
+ await keyIntoPod(agent, home, log);
336
+ await connectionsIntoPod(agent, home, log);
337
+ await ensureInboxDoor(agent, urls, log);
338
+
339
+ await agent.connect();
340
+
341
+ // The client surfaces, on the pod's own origin: the Mastodon API a phone app
342
+ // speaks, the write API, nodeinfo, and behind the door the admin routes and
343
+ // the web client. Same code the standalone agent serves, minus the routes
344
+ // that only mean something to a process of one's own.
345
+ const authorities = new FixedAuthorities(base);
346
+ agent.authorities = authorities;
347
+ const surface = buildAdminSurface({
348
+ agent,
349
+ log,
350
+ gate: makeGate(gateToken, { secureCookie: authorities.secure }),
351
+ allowed: authorities,
352
+ embedded: true,
353
+ basePath: uiPath,
354
+ publicOrigin: base,
355
+ scheme: new URL(base).protocol,
356
+ });
357
+
358
+ // A delivery landing in the inbox — through the door, or from any other
359
+ // writer — wakes the drain at once. Only additions, and only in the inbox:
360
+ // the drain's own DELETEs must not call it back.
361
+ let coalesce = null;
362
+ let onChanged = null;
363
+ if (typeof resourceStore?.on === 'function') {
364
+ const inbox = urls.inbox;
365
+ onChanged = (identifier, activity) => {
366
+ const kind = String(activity?.value ?? activity ?? '');
367
+ if (!identifier?.path?.startsWith(inbox)) return;
368
+ if (!kind.endsWith('Create') && !kind.endsWith('Add')) return;
369
+ if (coalesce) return;
370
+ coalesce = setTimeout(() => {
371
+ coalesce = null;
372
+ if (!agent.viewer) agent.intake?.drain().catch((e) => log(`drain: ${e.message}`));
373
+ }, DRAIN_COALESCE_MS);
374
+ coalesce.unref?.();
375
+ };
376
+ resourceStore.on('changed', onChanged);
377
+ }
378
+
379
+ const stop = async () => {
380
+ if (onChanged) resourceStore.off('changed', onChanged);
381
+ surface.streaming?.stop?.();
382
+ clearTimeout(coalesce);
383
+ agent.intake?.stop();
384
+ agent.tagfeed?.stop();
385
+ agent.bskyfeed?.stop();
386
+ agent.deliverer?.stop();
387
+ agent.importer?.stop();
388
+ clearInterval(agent.schedTimer);
389
+ clearInterval(agent.refreshTimer);
390
+ agent.publisher?.stopPolls();
391
+ // Same order the standalone agent's shutdown uses: write what is pending,
392
+ // then let go of the lease so the next agent need not wait out the TTL.
393
+ await Promise.allSettled([
394
+ agent.store.flush(),
395
+ agent.viewer ? Promise.resolve() : agent.lease?.release(),
396
+ ]);
397
+ };
398
+
399
+ // podHome and actorUrl are the identity's own locations on the pod. They are
400
+ // returned rather than rebuilt by the caller so the root name lives here.
401
+ return {
402
+ agent, handle, home, surface, host: authorities.host,
403
+ podHome: urls.home, actorUrl: urls.actor, inboxUrl: urls.inbox, stop,
404
+ };
405
+ }
@@ -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-server",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "The FediPod Server: a full ActivityPub server as a Community Solid Server component.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
package/run-agent.mjs CHANGED
@@ -28,28 +28,29 @@ import os from 'node:os';
28
28
  import path from 'node:path';
29
29
  import { pathToFileURL } from 'node:url';
30
30
 
31
- import { PodStore } from './lib/store.mjs';
32
- import { apRoot, rootOf, writeJsonAtomic } from './lib/home.mjs';
33
- import { ensureTrustedTls } from './lib/certs.mjs';
34
- import { storageFor } from './lib/storage.mjs';
35
- import { resolveKeys } from './lib/keys.mjs';
36
- import { RemotePod } from './lib/remote.mjs';
37
- import { Deliverer } from './lib/deliver.mjs';
38
- import { Publisher } from './lib/publisher.mjs';
39
- import { Intake } from './lib/intake.mjs';
40
- import { TagFeed } from './lib/tagfeed.mjs';
41
- import { ImportWorker } from './lib/import.mjs';
42
- import { Atproto } from './lib/atproto.mjs';
43
- import { FediAccounts } from './lib/fediacct.mjs';
44
- import { AcctFeed } from './lib/acctfeed.mjs';
45
- import { BskyFeed } from './lib/bskyfeed.mjs';
46
- import { BskyGroup } from './lib/bskygroup.mjs';
47
- import { Lease } from './lib/lease.mjs';
48
- import { startAdmin } from './lib/admin.mjs';
49
- import { exposureProblem, hostLabel } from './lib/guard.mjs';
50
- import { pendingSteps } from './lib/migrate.mjs';
51
- import { apUrls, assertionKeyId , publicHandle } from './lib/wire.mjs';
52
- import { followActor, unfollowActor, resolveHandle } from './lib/social.mjs';
31
+ import { PodStore } from './lib/core/store.mjs';
32
+ import { apRoot, rootOf, writeJsonAtomic } from './lib/device/home.mjs';
33
+ import { ensureTrustedTls } from './lib/device/certs.mjs';
34
+ import { storageFor } from './lib/core/storage.mjs';
35
+ import { resolveKeys } from './lib/core/keys.mjs';
36
+ import { RemotePod } from './lib/device/remote.mjs';
37
+ import { Deliverer } from './lib/core/deliver.mjs';
38
+ import { Publisher } from './lib/core/publisher/index.mjs';
39
+ import { Intake } from './lib/core/intake/index.mjs';
40
+ import { TagFeed } from './lib/connections/tagfeed.mjs';
41
+ import { ImportWorker } from './lib/connections/import.mjs';
42
+ import { Atproto } from './lib/connections/atproto.mjs';
43
+ import { FediAccounts } from './lib/connections/fediacct.mjs';
44
+ import { vaultsFor } from './lib/connections/vault.mjs';
45
+ import { AcctFeed } from './lib/connections/acctfeed.mjs';
46
+ import { BskyFeed } from './lib/connections/bskyfeed.mjs';
47
+ import { BskyGroup } from './lib/connections/bskygroup.mjs';
48
+ import { Lease } from './lib/core/lease.mjs';
49
+ import { startAdmin } from './lib/device/admin/index.mjs';
50
+ import { exposureProblem, hostLabel } from './lib/shared/guard.mjs';
51
+ import { pendingSteps } from './lib/device/migrate.mjs';
52
+ import { apUrls, assertionKeyId , publicHandle } from './lib/core/wire.mjs';
53
+ import { followActor, unfollowActor, resolveHandle } from './lib/core/social.mjs';
53
54
 
54
55
  export class Agent {
55
56
  constructor({ home, log }) {
@@ -344,13 +345,20 @@ export class Agent {
344
345
  // The CSV-import worker: paced, resumable, armed only while active.
345
346
  this.importer?.stop();
346
347
  this.importer = new ImportWorker({ agent: this, log: this.log });
348
+ // Where the credentials for accounts held on OTHER servers are kept.
349
+ const vaults = vaultsFor({ embedded: this.embedded, home: this.home, store: this.store });
347
350
  // The Bluesky connection, when one exists. Stamped to this actor; a
348
351
  // credential connected for another identity is treated as absent.
349
- this.atproto = new Atproto({ localDir: this.home, actorId: this.urls.actor, log: this.log });
352
+ this.atproto = new Atproto({
353
+ localDir: this.home, actorId: this.urls.actor, log: this.log, vault: vaults.bluesky,
354
+ });
350
355
  this.publisher.atproto = this.atproto;
351
356
  // Fediverse accounts the owner holds elsewhere. Same custody rules as the
352
357
  // Bluesky credential, and the same absence when stamped for someone else.
353
- this.fediaccts = new FediAccounts({ localDir: this.home, actorId: this.urls.actor, log: this.log });
358
+ this.fediaccts = new FediAccounts({
359
+ localDir: this.home, actorId: this.urls.actor, log: this.log,
360
+ vault: vaults.fedi, apps: vaults.apps,
361
+ });
354
362
  this.acctfeed?.stop();
355
363
  this.acctfeed = null;
356
364
  this.bskyfeed?.stop();
@@ -773,7 +781,7 @@ export async function startAgent({
773
781
  // Is a newer FediPod published? Once at boot and daily after; the answer
774
782
  // rides /status and the record page offers the update.
775
783
  const updateTick = async () => {
776
- const { checkLatest } = await import('./lib/update.mjs');
784
+ const { checkLatest } = await import('./lib/device/update.mjs');
777
785
  const u = await checkLatest();
778
786
  if (u) {
779
787
  agent.updateInfo = u;
@@ -0,0 +1,145 @@
1
+ // actors.js — the other actors on this machine: the picker, going to one,
2
+ // starting a stopped one, and the form for a new one.
3
+
4
+ // Somewhere to go, and nothing else. A stopped actor is left out — the link
5
+ // would land on nothing. The one you are on keeps its place in the row but
6
+ // offers only `app`, because its admin page is the page you are reading.
7
+
8
+ let actors = [];
9
+
10
+ async function renderOthers() {
11
+ const { json } = await api('/profiles');
12
+ actors = json?.identities || [];
13
+ const sel = $('actor-pick');
14
+ sel.textContent = '';
15
+ // The fediverse address, which is what the actor IS to everyone else, and the
16
+ // only form that stays distinct: two identities can share a local handle, but
17
+ // never a handle AND a pod. A stopped one has no address to report — nothing
18
+ // answered — so it falls back to the name it is filed under.
19
+ const label = (r) => (r.address || r.handle || r.name)
20
+ + (r.mode && r.mode !== 'active' ? ` (${r.mode})` : '') + (r.mode ? '' : ' (stopped)');
21
+ const seen = {};
22
+ for (const r of actors) seen[label(r)] = (seen[label(r)] || 0) + 1;
23
+
24
+ for (const [i, r] of actors.entries()) {
25
+ const o = document.createElement('option');
26
+ o.value = String(i);
27
+ o.selected = !!r.current; // it opens showing where you already are
28
+ o.textContent = label(r) + (seen[label(r)] > 1 && r.port ? ` :${r.port}` : '');
29
+ sel.appendChild(o);
30
+ }
31
+ // The way to a NEW actor rides the same dropdown as the existing ones.
32
+ const add = document.createElement('option');
33
+ add.value = '__add';
34
+ add.textContent = '+ add a new account…';
35
+ sel.appendChild(add);
36
+ }
37
+
38
+ // Going to an actor commits a navigation and, for a stopped one, a POST that
39
+ // boots its pod — so it must run once per choice. Pressing Enter on the select
40
+ // fires BOTH a keydown (handled below) and a native `change`, so without a
41
+ // guard a keyboard commit would start the actor twice.
42
+ let goingToActor = false;
43
+ const goToActor = async () => {
44
+ if (goingToActor) return;
45
+ if ($('actor-pick').value === '__add') { renderOthers(); openNewActor(); return; }
46
+ const r = actors[Number($('actor-pick').value)];
47
+ if (!r || r.current) return;
48
+ goingToActor = true;
49
+ try {
50
+ if (r.mode) { location.href = r.admin; return; }
51
+ $('actor-pick').disabled = true;
52
+ say(`starting ${r.name}`);
53
+ const started = await write('/start-actor', { name: r.name }, `${r.name} is up`);
54
+ $('actor-pick').disabled = false;
55
+ if (started?.url) location.href = `${started.url}admin/`;
56
+ else renderOthers(); // put the picker back on the current actor
57
+ } finally {
58
+ goingToActor = false;
59
+ }
60
+ };
61
+ // Choosing one goes to its record, as it always has.
62
+ //
63
+ // Arrowing is the one case that must not: on a closed select every Arrow
64
+ // keypress fires `change`, so a keyboard user browsing the list would be
65
+ // carried off to the first actor they passed — and for a stopped one that
66
+ // boots its pod. An arrow key arms a flag and the change it causes is ignored;
67
+ // Enter commits. A mouse never sets the flag, so clicking behaves as before.
68
+ let arrowing = false;
69
+ $('actor-pick').addEventListener('keydown', (ev) => {
70
+ if (['ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'].includes(ev.key)) arrowing = true;
71
+ if (ev.key === 'Enter') { arrowing = false; goToActor(); }
72
+ });
73
+ $('actor-pick').addEventListener('pointerdown', () => { arrowing = false; });
74
+ $('actor-pick').addEventListener('change', () => {
75
+ if (!arrowing) goToActor();
76
+ arrowing = false;
77
+ });
78
+
79
+ // Setting up a new actor asks for exactly what this page cannot already tell
80
+ // it: the pod and the account behind it, and its permanent name. Display name,
81
+ // bio and pictures are the client's job now — Phanpy's profile editor writes
82
+ // them through /api/v1/accounts/update_credentials. Where the private data goes
83
+ // is not a question either: it lands beside the credential, and `state --to`
84
+ // moves it afterwards.
85
+
86
+ const picked = (name) => document.querySelector(`input[name=${name}]:checked`)?.value;
87
+
88
+ function newActorRows() {
89
+ const mode = picked('newMode');
90
+ $('new-row-podname').hidden = mode !== 'new';
91
+ $('new-row-pod').hidden = mode !== 'existing';
92
+ }
93
+ for (const el of document.querySelectorAll('input[name=newMode]')) {
94
+ el.addEventListener('change', newActorRows);
95
+ }
96
+
97
+ // While the form is up the page IS the new actor's setup — so the record of
98
+ // the actor you came from goes away, list included. Which panes were showing
99
+ // is remembered rather than recomputed: whether Group and Inbox belong is a
100
+ // decision render() already made.
101
+ const RECORD_PANES = ['pane-identity', 'pane-group', 'pane-inbox', 'rail'];
102
+ let putBack = [];
103
+
104
+ function showNewActor(on) {
105
+ if (on) closePanels(); // nothing of the old actor left open behind it
106
+ document.body.classList.toggle('adding', on); // see body.adding in the CSS
107
+ $('new-actor-form').hidden = !on;
108
+ if (on) {
109
+ putBack = RECORD_PANES.filter(id => !$(id).hidden);
110
+ for (const id of putBack) $(id).hidden = true;
111
+ } else {
112
+ for (const id of putBack) $(id).hidden = false;
113
+ putBack = [];
114
+ }
115
+ }
116
+
117
+ // `add a new account` is the last item of the Local Actors dropdown; choosing
118
+ // it opens the form in place. Arriving with ?new does the same.
119
+ function openNewActor() {
120
+ showNewActor(true);
121
+ newActorRows();
122
+ $('new-handle').focus();
123
+ }
124
+ $('new-actor-cancel').addEventListener('click', () => { showNewActor(false); say('nothing changed'); });
125
+
126
+ $('new-actor-form').addEventListener('submit', async (ev) => {
127
+ ev.preventDefault();
128
+ const handle = $('new-handle').value.trim();
129
+ const answers = {
130
+ handle,
131
+ kind: picked('newKind'),
132
+ mode: picked('newMode'),
133
+ issuer: $('new-issuer').value.trim(),
134
+ email: $('new-email').value.trim(),
135
+ password: $('new-password').value,
136
+ };
137
+ if (answers.mode === 'new') answers.podName = $('new-podname').value.trim() || handle;
138
+ else answers.pod = $('new-pod').value.trim();
139
+ $('new-actor-go').disabled = true;
140
+ say(`setting up ${handle || 'the new actor'} — this takes a while`);
141
+ const r = await write('/new-actor', answers, `setting up ${handle}`);
142
+ $('new-actor-go').disabled = false;
143
+ if (r?.url) location.href = r.url; // its own page, where the progress is
144
+ });
145
+