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,322 @@
1
+ // owner.mjs — the record: what the owner's page reads about this identity,
2
+ // and the one write that edits it.
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { announceModeration } from '../../../core/social.mjs';
7
+ import { addRemoveActivity, publicHandle } from '../../../core/wire.mjs';
8
+ import { hashPassword } from '../../../client/masto/index.mjs';
9
+ import { identityHomes, rootOf, tildify, defaultProfile } from '../../home.mjs';
10
+ import { localFetch } from '../../../client/localapi.mjs';
11
+ import { AGENT_VERSION } from '../static.mjs';
12
+ import { secureOrigin } from '../origins.mjs';
13
+
14
+ // The identity itself. Changing any of these means a different actor at a
15
+ // different address, which is a new setup, not an edit.
16
+ const PERMANENT_CONFIG = ['handle', 'remotePod', 'issuer', 'root', 'kind'];
17
+ // Config the actor document carries, so a change is not real until it is
18
+ // republished.
19
+ const WIRE_CONFIG = ['name', 'summary', 'icon', 'image', 'fields', 'approveJoins', 'moderators'];
20
+
21
+ export async function get(p, ctx, req, res, url) { // eslint-disable-line no-unused-vars
22
+ const { agent, log, allowed, embedded, port, publicOrigin, versionOnDisk, isGroup } = ctx; // eslint-disable-line no-unused-vars
23
+ const json = (res, status, obj) => { ctx.json(res, status, obj); return true; };
24
+
25
+ if (req.method === 'GET' && p === '/status') return json(res, 200, agent.status());
26
+ // The moderation queue: what listed moderators asked for over federation,
27
+ // waiting for the operator to vouch (or not). See POST /modqueue.
28
+ if (req.method === 'GET' && p === '/modqueue') {
29
+ return json(res, 200, agent.store.getConfig()?.kind === 'group'
30
+ ? agent.store.read('modqueue.json', []) : []);
31
+ }
32
+ // The inbox gateway (optional, opt-in). Its state, and the shadow-mode
33
+ // measurement that says whether it is worth trusting. The HMAC secret is
34
+ // never returned — only whether one is set.
35
+ if (req.method === 'GET' && p === '/gateway') {
36
+ const g = agent.store.getConfig()?.gateway || null;
37
+ return json(res, 200, {
38
+ configured: !!(g && g.url),
39
+ url: g?.url || null, webId: g?.webId || null,
40
+ frontActor: g?.frontActor || null,
41
+ mode: g?.mode || 'off', hasSecret: !!g?.hmacSecret,
42
+ stats: agent.store.read('gateway-stats.json', { verified: 0, unverified: 0, lastAt: null }),
43
+ });
44
+ }
45
+ // The other identities on this machine, so the page can link to each one.
46
+ // Their `agent.json` is the only file read — a port and a handle. A
47
+ // sibling's credential and keys are never opened; anything else shown
48
+ // here comes from that agent answering /status for itself.
49
+ if (req.method === 'GET' && p === '/profiles') {
50
+ const here = path.resolve(agent.home || '');
51
+ // Which identity a plain command means: the last one started. A
52
+ // different question from `current`, which is the page you are on.
53
+ const wasLast = defaultProfile(rootOf(here));
54
+ const rows = await Promise.all(identityHomes(rootOf(here)).map(async ({ name, dir }) => {
55
+ // agent.json holds a port AND a handle — setup records both, exactly
56
+ // so the named origin can be built before that agent has said a word.
57
+ let rec = {};
58
+ try { rec = JSON.parse(fs.readFileSync(path.join(dir, 'agent.json'), 'utf8')) || {}; } catch {}
59
+ const port = rec.port || null;
60
+ // A directory under profiles/ is not an identity until it holds a
61
+ // credential or has run somewhere. A half-finished setup leaves one
62
+ // behind, and listing it offers a page that cannot exist yet.
63
+ if (!port && !fs.existsSync(path.join(dir, 'credential.json'))) return null;
64
+ const current = path.resolve(dir) === here;
65
+ const live = current ? agent.status() : port
66
+ ? await localFetch(agent.home, port, '/status', { timeout: 1200 })
67
+ .then(r => r.json()).catch(() => null)
68
+ : null;
69
+ // The handle is right here — spending it on the origin is the whole
70
+ // point of having asked that agent who it is. A stopped one never
71
+ // answers, so its recorded handle stands in: the link has to be named
72
+ // before you get there, or starting it lands you on the shared origin.
73
+ const handle = live?.handle || rec.handle || null;
74
+ const origin = port
75
+ ? secureOrigin(handle, port)
76
+ : null;
77
+ // The fediverse address, assembled from the two things /status has:
78
+ // the handle, and the pod host its actor URL sits on.
79
+ let address = null;
80
+ if (live?.handle && live?.actor) {
81
+ try {
82
+ const front = live.actor.match(/\/u\/([^/]+)\/ap\/actor\/?$/)?.[1];
83
+ address = `${front || live.handle}@${new URL(live.actor).host}`;
84
+ } catch { /* not a URL yet */ }
85
+ }
86
+ return {
87
+ name, port, current,
88
+ admin: origin ? `${origin}/admin/` : null,
89
+ app: origin ? `${origin}/` : null,
90
+ handle,
91
+ address,
92
+ lastUsed: name === wasLast,
93
+ kind: live?.kind || null,
94
+ mode: live ? live.mode : null,
95
+ };
96
+ }));
97
+ return json(res, 200, { identities: rows.filter(Boolean) });
98
+ }
99
+ if (req.method === 'GET' && p === '/blocks') return json(res, 200, agent.store.getBlocklist());
100
+ if (req.method === 'GET' && p === '/log') return json(res, 200, { lines: agent.logLines(200) });
101
+ if (req.method === 'GET' && p === '/deadletter') return json(res, 200, { items: agent.store.getDeadLetters() });
102
+
103
+ if (req.method === 'GET' && p === '/config') {
104
+ const cfg = agent.store.getConfig();
105
+ if (!cfg) return json(res, 409, { error: 'agent not configured — set it up at /admin/setup/' });
106
+ const urls = agent.urls || agent.publisher?.urls || null;
107
+ const wfHost = urls ? new URL(urls.base).host : null;
108
+ // A fronted identity's address is its name AT THE FRONT — the front
109
+ // serves the actor under that name, whatever this pod calls it.
110
+ const address = cfg.gateway?.frontActor
111
+ ? `@${publicHandle(cfg)}@${new URL(cfg.gateway.frontActor).host}`
112
+ : (wfHost ? `@${cfg.handle}@${wfHost}` : null);
113
+ return json(res, 200, {
114
+ // permanent
115
+ handle: cfg.handle, remotePod: cfg.remotePod, issuer: cfg.issuer,
116
+ root: cfg.root || null, kind: cfg.kind || 'person',
117
+ actor: urls?.actor || null, webId: agent.readCredential?.()?.webId || null,
118
+ // The opaque id the client addresses this actor by, so the record can
119
+ // link straight at its profile there. Derived from the actor URL the
120
+ // same way every other id is — computed here rather than in the page,
121
+ // which has no business knowing how they are made.
122
+ accountId: urls?.actor ? agent.store.idFor(urls.actor) : null,
123
+ address,
124
+ // editable
125
+ name: cfg.name || null, summary: cfg.summary || null, icon: cfg.icon || null,
126
+ image: cfg.image || null, fields: cfg.fields || [],
127
+ approveJoins: !!cfg.approveJoins, review: !!cfg.review,
128
+ aliases: cfg.aliases || [],
129
+ autoAcceptFollows: !!cfg.autoAcceptFollows,
130
+ // never the record itself
131
+ hasUiPassword: !!cfg.uiPassword,
132
+ quiescedAt: cfg.quiescedAt || null, movedTo: cfg.movedTo || null,
133
+ // Per-machine, so it comes from the credential file, not pod config —
134
+ // and it is a fact here, not a setting: moving it means moving data,
135
+ // which is `fedipod state --to <url>`.
136
+ privateRoot: agent.readCredential?.()?.privateRoot || null,
137
+ mode: agent.status?.().mode || null, port, home: tildify(agent.home) || null,
138
+ update: agent.updateInfo || null,
139
+ // What this process is running, against what a restart would run:
140
+ // the checkout can move while an agent stays up.
141
+ version: AGENT_VERSION,
142
+ versionOnDisk: versionOnDisk(),
143
+ pendingUpgrade: agent.pendingUpgrade || [],
144
+ // The connected Bluesky account, non-secret half. `connected` is the
145
+ // credential's word, so a config entry orphaned by a deleted
146
+ // atproto.json shows as disconnected rather than pretending.
147
+ atproto: cfg.atproto
148
+ ? { ...cfg.atproto, connected: !!agent.atproto?.connected() }
149
+ : null,
150
+ // Connected fediverse accounts, from the credentials themselves
151
+ // rather than from config — a roster entry whose file was deleted
152
+ // should stop being listed, not linger.
153
+ fediAccounts: agent.fediaccts?.status() || [],
154
+ origins: {
155
+ loopback: publicOrigin
156
+ || `https://localhost:${port}/`,
157
+ named: publicOrigin
158
+ ? null
159
+ : allowed.label
160
+ ? (true
161
+ ? `https://${allowed.label}.localhost:${port}/`
162
+ : `http://${allowed.label}.localhost:${port}/`)
163
+ : null,
164
+ },
165
+ });
166
+ }
167
+ // How far the CSV import has gotten — polled by the CLI while it runs.
168
+ if (req.method === 'GET' && p === '/import') {
169
+ if (!agent.importer) return json(res, 409, { error: 'agent not connected yet' });
170
+ return json(res, 200, agent.importer.progress());
171
+ }
172
+ // Group-only: who is here, and what the group has carried. A group cannot
173
+ // force an unfollow, so muting — declining to carry — is its whole lever.
174
+ if (req.method === 'GET' && p === '/members') {
175
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
176
+ const muted = agent.store.getMuted().actors;
177
+ return json(res, 200, {
178
+ members: agent.store.getContacts().followers
179
+ .map(f => ({
180
+ actor: f.actor,
181
+ handle: agent.store.handleOf(f.actor),
182
+ inbox: f.sharedInbox || f.inbox,
183
+ muted: muted.includes(f.actor),
184
+ })),
185
+ });
186
+ }
187
+ if (req.method === 'GET' && p === '/announced') {
188
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
189
+ return json(res, 200, {
190
+ announced: agent.store.getStatuses().filter(s => s.announcedAt)
191
+ .map(s => ({ noteId: s.noteId, actor: s.actor, announcedAt: s.announcedAt })),
192
+ });
193
+ }
194
+ if (req.method === 'GET' && p === '/requests') {
195
+ // Not group-only any more: a person queues unverifiable follows here
196
+ // too, and a queue with no way to read it is worse than no queue.
197
+ return json(res, 200, {
198
+ approveJoins: !!agent.store.getConfig()?.approveJoins,
199
+ requests: agent.store.getRequests().map(r => ({ actor: r.actor, at: r.at })),
200
+ });
201
+ }
202
+ if (req.method === 'GET' && p === '/pending') {
203
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
204
+ return json(res, 200, {
205
+ review: !!agent.store.getConfig()?.review,
206
+ pending: agent.store.getPending(),
207
+ });
208
+ }
209
+
210
+ return false;
211
+ }
212
+
213
+ export async function post(p, body, ctx, req, res) { // eslint-disable-line no-unused-vars
214
+ const { agent, log, allowed, embedded, port, publicOrigin, versionOnDisk, isGroup } = ctx; // eslint-disable-line no-unused-vars
215
+ const json = (res, status, obj) => { ctx.json(res, status, obj); return true; };
216
+ switch (p) {
217
+ // ---- the record, edited from the page at /admin/ ----
218
+ case '/config': {
219
+ const fixed = PERMANENT_CONFIG.filter(k => k in body);
220
+ if (fixed.length) {
221
+ return json(res, 400, {
222
+ error: `${fixed.join(', ')} cannot be changed — that is the identity itself, not a setting`,
223
+ });
224
+ }
225
+ for (const k of ['approveJoins', 'review']) {
226
+ if (k in body && !isGroup()) return json(res, 404, { error: 'not a group' });
227
+ }
228
+ // A user acting here outranks an idle active agent on another device:
229
+ // claim the lease rather than write state that one will clobber.
230
+ await agent.requestTakeover?.();
231
+ // Merge, never replace: the UI password and anything else set later
232
+ // must survive an edit that never mentions it.
233
+ const cfg = { ...agent.store.getConfig() };
234
+ if ('name' in body) {
235
+ if (!body.name) return json(res, 400, { error: 'a display name is required' });
236
+ cfg.name = String(body.name);
237
+ }
238
+ if ('summary' in body) cfg.summary = body.summary || undefined;
239
+ if ('icon' in body) cfg.icon = body.icon || undefined;
240
+ // The banner and the labelled rows, so a client and this page edit one
241
+ // record rather than each other's leftovers.
242
+ if ('image' in body) cfg.image = body.image || undefined;
243
+ if ('fields' in body) {
244
+ cfg.fields = (Array.isArray(body.fields) ? body.fields : [])
245
+ .filter(f => f?.name?.trim())
246
+ .map(f => ({ name: String(f.name).trim(), value: String(f.value ?? '').trim() }));
247
+ }
248
+ if ('approveJoins' in body) cfg.approveJoins = !!body.approveJoins;
249
+ if ('review' in body) cfg.review = !!body.review;
250
+ // A person's queue gate: on, and an inbound Follow is accepted on
251
+ // arrival instead of waiting in the requests queue. Local behavior,
252
+ // not on the wire — but intake reads the LIVE config, so it is
253
+ // patched below whether or not anything republishes.
254
+ if ('autoAcceptFollows' in body) cfg.autoAcceptFollows = !!body.autoAcceptFollows;
255
+ // The moderator roster (FEP-1b12): actor IRIs whose federated
256
+ // moderation asks are queued, published as attributedTo.
257
+ let rosterDiff = null;
258
+ if ('moderators' in body) {
259
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
260
+ const next = [...new Set((Array.isArray(body.moderators) ? body.moderators : [])
261
+ .map(String).filter(m => /^https?:\/\/\S+$/.test(m)))];
262
+ const prev = cfg.moderators || [];
263
+ rosterDiff = {
264
+ added: next.filter(m => !prev.includes(m)),
265
+ removed: prev.filter(m => !next.includes(m)),
266
+ };
267
+ cfg.moderators = next;
268
+ }
269
+ if ('password' in body) {
270
+ // '' clears it. The UI password is what turns the instant OAuth
271
+ // redirect into a login form; switching that off is a real choice.
272
+ if (body.password) cfg.uiPassword = hashPassword(body.password);
273
+ else delete cfg.uiPassword;
274
+ }
275
+ const republish = WIRE_CONFIG.some(k => k in body);
276
+ agent.store.setConfig(cfg);
277
+ if ('autoAcceptFollows' in body && agent.publisher) {
278
+ agent.publisher.config.autoAcceptFollows = cfg.autoAcceptFollows;
279
+ }
280
+ if (republish && agent.publisher) {
281
+ Object.assign(agent.publisher.config, {
282
+ name: cfg.name, summary: cfg.summary, icon: cfg.icon,
283
+ image: cfg.image, fields: cfg.fields,
284
+ approveJoins: !!cfg.approveJoins,
285
+ moderators: cfg.moderators,
286
+ aliases: cfg.aliases,
287
+ });
288
+ }
289
+ await agent.store.flush();
290
+ // Publishing is only half of it: publishProfile re-fetches the public
291
+ // documents unauthenticated and reports the ones a stranger's server
292
+ // could not read. Carrying that back is what lets this be the only
293
+ // republish control there is.
294
+ const pub = republish ? await agent.publisher.publishProfile() : null;
295
+ // Roster changes are announced to the membership (FEP-1b12), so
296
+ // their servers can mirror who moderates here.
297
+ if (rosterDiff && agent.publisher) {
298
+ const urls = agent.publisher.urls;
299
+ for (const [type, list] of [['Add', rosterDiff.added], ['Remove', rosterDiff.removed]]) {
300
+ for (const m of list) {
301
+ await announceModeration(agent, addRemoveActivity({
302
+ urls, type, object: m, target: urls.moderators, serial: Date.now(),
303
+ })).catch(e => log(`moderation announce failed: ${e.message}`));
304
+ }
305
+ }
306
+ }
307
+ return json(res, 200, {
308
+ ok: true, published: republish,
309
+ ...(pub?.unreachable?.length ? { unreachable: pub.unreachable } : {}),
310
+ config: {
311
+ name: cfg.name || null, summary: cfg.summary || null, icon: cfg.icon || null,
312
+ image: cfg.image || null, fields: cfg.fields || [],
313
+ approveJoins: !!cfg.approveJoins, review: !!cfg.review,
314
+ autoAcceptFollows: !!cfg.autoAcceptFollows,
315
+ ...(isGroup() ? { moderators: cfg.moderators || [] } : {}),
316
+ hasUiPassword: !!cfg.uiPassword,
317
+ },
318
+ });
319
+ }
320
+ default: return false;
321
+ }
322
+ }