fedipod-bb 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,991 @@
1
+ // forum-agent.mjs — the forum's host: one process that runs a FediPod group
2
+ // agent for every category, drains the forum's one inbox and routes each
3
+ // activity to the category it names, places carried posts into topics, and
4
+ // holds one lease for the whole forum so several moderators' devices can
5
+ // share the job — whichever is up acts, the rest watch and take over.
6
+ //
7
+ // What is reused is the whole group: a category's store, publisher,
8
+ // deliverer and intake are FediPod's own, configured as a group. What is new
9
+ // is the routing, the topic placement, and the lifecycle around N of them.
10
+
11
+ import crypto from 'node:crypto';
12
+ import path from 'node:path';
13
+ import fs from 'node:fs';
14
+ import { PodStore } from 'fedipod/core/store.mjs';
15
+ import { storageFor as defaultStorageFor } from 'fedipod/core/storage.mjs';
16
+ import { Deliverer } from 'fedipod/core/deliver.mjs';
17
+ import { Publisher } from 'fedipod/core/publisher/index.mjs';
18
+ import { Intake } from 'fedipod/core/intake/index.mjs';
19
+ import { C2S } from 'fedipod/client/c2s.mjs';
20
+ import { Lease } from 'fedipod/core/lease.mjs';
21
+ import { resolveKeys } from 'fedipod/core/keys.mjs';
22
+ import { assertionKeyId, orderedCollection } from 'fedipod/core/wire.mjs';
23
+ import * as podFeatured from 'fedipod/pod/featured.mjs';
24
+ import { resolveHandle } from 'fedipod/core/social.mjs';
25
+ import * as podInbox from 'fedipod/pod/inbox.mjs';
26
+ import { forumUrls, ROOT, isSlug } from './urls.mjs';
27
+ import * as topics from './topics.mjs';
28
+ import * as publish from './publish.mjs';
29
+ import * as moderation from './moderation.mjs';
30
+ import * as access from './access.mjs';
31
+ import { provisionForum, provisionCategory } from './provision.mjs';
32
+ import * as settings from './settings.mjs';
33
+
34
+ import { ForumIntake } from './forum-intake.mjs';
35
+ export { ForumIntake };
36
+
37
+ const idOf = (v) => (typeof v === 'string' ? v : v?.id);
38
+ const arr =(v) => (v === undefined || v === null ? [] : [].concat(v));
39
+ const HEARTBEAT_MS = 10 * 60_000;
40
+ // How often a moderator's ask is looked at again. Short enough that asking for
41
+ // something and watching it happen feels like one act.
42
+ const ASK_SWEEP_MS = 60_000;
43
+ const VIEWER_REFRESH_MS = 5 * 60_000;
44
+
45
+ export class ForumAgent {
46
+ // `remote` and `storageFor` are injectable so a test can stand a pod in.
47
+ constructor({ home, log = () => {}, remote = null, storageFor = defaultStorageFor, push = true, pollSeconds = null }) {
48
+ this.home = home;
49
+ this.log = log;
50
+ this.remote = remote;
51
+ this.storageFor = storageFor;
52
+ this.push = push;
53
+ this.pollSeconds = pollSeconds;
54
+ this.categories = [];
55
+ this.viewer = true;
56
+ }
57
+
58
+ // This installation's name for itself, minted once and kept beside the
59
+ // credential. The lease lets a holder reclaim its own without waiting, so
60
+ // whether this is stable decides whether a RESTART is the same host coming
61
+ // back or a second one arriving: left to the lease's own default it was a
62
+ // fresh name every time, the old lease still had minutes of its five to run,
63
+ // and the forum came back up read-only — not draining its inbox, not applying
64
+ // a moderator's ask — until it expired. Which is what a restart to pick up a
65
+ // fix looked like from the outside: the fix changing nothing.
66
+ hostId() {
67
+ const at = path.join(this.home, 'host-id');
68
+ try { return fs.readFileSync(at, 'utf8').trim() || null; } catch { /* not yet */ }
69
+ try {
70
+ const made = crypto.randomUUID();
71
+ fs.writeFileSync(at, made + '\n', { mode: 0o600 });
72
+ return made;
73
+ } catch {
74
+ // Unwritable: a fresh name each time is honest, since this host cannot
75
+ // prove it is the one that held the lease.
76
+ return null;
77
+ }
78
+ }
79
+
80
+ readCredential() {
81
+ try { return JSON.parse(fs.readFileSync(path.join(this.home, 'credential.json'), 'utf8')); }
82
+ catch { return null; }
83
+ }
84
+
85
+ status() {
86
+ return {
87
+ mode: !this.store ? 'unconfigured' : this.viewer ? 'viewer' : 'active',
88
+ handle: this.config?.handle || null,
89
+ actor: this.site?.actor || null,
90
+ categories: this.categories.map(c => ({
91
+ slug: c.slug, actor: c.urls.actor,
92
+ members: c.store.getContacts().followers.length,
93
+ topics: topics.list(c.store).length,
94
+ queue: c.store.getQueue().length,
95
+ })),
96
+ inbox: this.intake?.inboxStats || null,
97
+ lastDrain: this.intake?.lastDrain || null,
98
+ push: this.intake?.wsState || 'n/a',
99
+ };
100
+ }
101
+
102
+ // The first act on a fresh pod: the forum's containers, its config, and
103
+ // nothing else — the actors are published on the first connect.
104
+ async init({ handle, name, categories = [], moderators = [], moderatorWebIds = [],
105
+ membersOnly = [], memberWebIds = {}, approveJoins = false, review = false, replyPolicy = 'open' }) {
106
+ const cred = this.readCredential();
107
+ if (!cred) throw new Error('no credential.json — make one first');
108
+ await this.attachRemote(cred);
109
+ const site = forumUrls(cred.remotePod, cred.root || ROOT);
110
+ await provisionForum(this.remote, site);
111
+ const store = new PodStore({ log: this.log });
112
+ store.attach(this.storageFor(site.state, (u, i) => this.remote.fetch(u, i)));
113
+ await store.load().catch(() => {});
114
+ const existing = store.getConfig() || {};
115
+ const cats = categories.map(c => (typeof c === 'string' ? { slug: c, name: c } : c));
116
+ for (const c of cats) if (!isSlug(c.slug)) throw new Error(`not a category slug: ${c.slug}`);
117
+ // A renamed forum has to be published again: the profile is written only
118
+ // when its digest changed or something asks, and a name lives in the
119
+ // actor document, not in the config alone.
120
+ // A changed name — the forum's or any category's — lives in a published
121
+ // actor, and the profile is only rewritten when its digest changed or
122
+ // something asks. So it asks.
123
+ const was = JSON.stringify((existing.categories || []).map(c => [c.slug, c.name]));
124
+ // Who may read what is written into the pod's own access rules, which are
125
+ // only written when something asks for a republish — so a change to them
126
+ // asks for one.
127
+ const access = JSON.stringify([existing.membersOnly || [], existing.memberWebIds || {}, existing.moderatorWebIds || []]);
128
+ const accessChanged = !!existing.handle && access !== JSON.stringify([membersOnly, memberWebIds, moderatorWebIds]);
129
+ const renamed = !!existing.handle
130
+ && ((name || handle) !== existing.name || was !== JSON.stringify(cats.map(c => [c.slug, c.name])));
131
+ store.setConfig({
132
+ ...existing, kind: 'application', handle, name: name || existing.name || handle,
133
+ ...(renamed || accessChanged ? { republish: true } : {}),
134
+ ...(accessChanged ? { reprovision: true } : {}),
135
+ remotePod: cred.remotePod, root: cred.root || ROOT,
136
+ categories: cats, moderators, moderatorWebIds, membersOnly, memberWebIds,
137
+ approveJoins, review, replyPolicy,
138
+ });
139
+ await store.flush();
140
+ this.log(`forum ${handle} initialised with ${cats.length} categor${cats.length === 1 ? 'y' : 'ies'}`);
141
+ return store.getConfig();
142
+ }
143
+
144
+ async attachRemote(cred) {
145
+ if (this.remote) return;
146
+ const { RemotePod } = await import('fedipod/remote');
147
+ this.remote = new RemotePod(cred, { log: this.log, home: this.home });
148
+ await this.remote.warmup();
149
+ }
150
+
151
+ async connect({ act = true } = {}) {
152
+ const cred = this.readCredential();
153
+ if (!cred) return false;
154
+ await this.attachRemote(cred);
155
+ // The forum's config says whether it is fronted; the config is on the pod
156
+ // at a place only the pod's own address names, so it is read first.
157
+ const plain = forumUrls(cred.remotePod, cred.root || ROOT);
158
+ this.store = new PodStore({ log: this.log });
159
+ this.store.attach(this.storageFor(plain.state, (u, i) => this.remote.fetch(u, i)));
160
+ await this.store.load();
161
+ this.config = this.store.getConfig();
162
+ if (!this.config) { this.log('credential present but the forum has no config — run init'); return false; }
163
+ const front = this.config.gateway?.front || null;
164
+ this.site = forumUrls(cred.remotePod, cred.root || ROOT, front ? { front, handle: this.config.handle } : {});
165
+ this.lease = new Lease({ url: this.site.state + 'lease.json', fetchImpl: (u, i) => this.remote.fetch(u, i), log: this.log,
166
+ id: this.hostId() });
167
+ this.viewer = act ? !(await this.lease.acquire()) : true;
168
+ this.categories = [];
169
+ for (const c of this.config.categories || []) this.categories.push(await this.buildCategory(c));
170
+ this.siteAgent = await this.buildSite();
171
+ // One map for every fronted id on this pod. Each Publisher installed its
172
+ // own on construction, and the last would have been the only one; the
173
+ // forum's covers the site and every category.
174
+ if (this.site.toPod && this.remote.setUrlMap) this.remote.setUrlMap((u) => this.toPod(u));
175
+ if (!act) return true;
176
+ if (this.viewer) {
177
+ this.startViewer();
178
+ this.log(`another device hosts ${this.config.handle} — viewing`);
179
+ return true;
180
+ }
181
+ await this.startActive();
182
+ return true;
183
+ }
184
+
185
+ // Any advertised id on this pod, fronted or not, to where it is written.
186
+ toPod(u) {
187
+ if (typeof u !== 'string') return u;
188
+ // A category with no front has no mapper; an address it does not map
189
+ // stays itself. Returning nothing here made every address the same as
190
+ // every other to a comparison of mapped addresses.
191
+ for (const cat of this.categories) { const m = cat.urls.toPod?.(u); if (m != null && m !== u) return m; }
192
+ return this.site.toPod ? this.site.toPod(u) : u;
193
+ }
194
+
195
+ // One FediPod group per category, its config written from the forum's.
196
+ async buildCategory({ slug, name, summary = null }) {
197
+ const urls = this.site.category(slug);
198
+ const store = new PodStore({ log: this.log });
199
+ store.attach(this.storageFor(urls.state, (u, i) => this.remote.fetch(u, i)));
200
+ await store.load().catch(() => {});
201
+ const prior = store.getConfig() || {};
202
+ const gw = this.config.gateway;
203
+ const config = {
204
+ ...prior,
205
+ kind: 'group', handle: slug, name: name || slug, ...(summary ? { summary } : {}),
206
+ remotePod: this.site.base, root: this.site.root + 'c/' + slug + '/',
207
+ forum: this.site.actor, inboxUrl: this.site.inbox,
208
+ moderators: this.config.moderators || [],
209
+ // A private category NEVER carries to someone it cannot let read: every
210
+ // join waits for a moderator, and admitting one requires a WebID the
211
+ // pod can grant. Without this a follower on a server with no WebID —
212
+ // Mastodon, Lemmy — would be sent the posts of a category whose pages
213
+ // they are refused, which is not private at all.
214
+ approveJoins: !!this.config.approveJoins || (this.config.membersOnly || []).includes(slug),
215
+ // Private: the posts are written for the people named and nobody else,
216
+ // so the category carries what is addressed to IT rather than to the
217
+ // world. An ordinary group refuses that, and is right to.
218
+ private: (this.config.membersOnly || []).includes(slug),
219
+ review: !!this.config.review,
220
+ // Fronted: the category's door at the Gateway, and its front id. The
221
+ // secret is the row's own, handed over once at attach.
222
+ ...(gw?.front ? { gateway: {
223
+ url: `${gw.front}/u/${slug}/ap/inbox/`, frontActor: `${gw.front}/u/${slug}/ap/actor`,
224
+ mode: gw.mode || 'trust', hmacSecret: gw.secrets?.[slug] || prior.gateway?.hmacSecret || null,
225
+ } } : {}),
226
+ };
227
+ store.setConfig(config);
228
+ const keys = await resolveKeys(store, { localDir: null, actorId: urls.actor, log: this.log });
229
+ const cat = { slug, urls, store, config, remote: this.remote };
230
+ cat.deliverer = new Deliverer({
231
+ store, rsaPrivate: keys.rsaPrivate, keyId: urls.actor + '#main-key', actorId: urls.actor,
232
+ edPrivate: keys.edPrivate, proofKeyId: assertionKeyId(urls), log: this.log, passive: true,
233
+ onGone: () => cat.publisher.publishCollections({ followers: true }),
234
+ });
235
+ cat.publisher = new Publisher({
236
+ config, remote: this.remote, store, deliverer: cat.deliverer,
237
+ publicKeyPem: keys.rsaPublicPem, assertionKey: keys.edPublicMultibase, log: this.log,
238
+ resolveMention: (h) => resolveHandle(cat, h),
239
+ resolveActor: (u) => cat.intake.fetchAP(u),
240
+ probeFetch: this.probeFetch || null,
241
+ });
242
+ cat.intake = new Intake({
243
+ config, urls, remote: this.remote, store, deliverer: cat.deliverer, publisher: cat.publisher,
244
+ log: this.log, lease: this.lease, push: false,
245
+ });
246
+ cat.intake.recentNotes = new Map();
247
+ // A private category's posts sit behind their authors' own access rules,
248
+ // and a Solid pod has never heard of an HTTP signature. So when the
249
+ // ordinary fetch is refused, ask again as the forum's own WebID — the one
250
+ // every member's rule names, and the reason the forum is on that list.
251
+ const signedFetchAP = cat.intake.fetchAP.bind(cat.intake);
252
+ cat.intake.fetchAP = async (url) => {
253
+ const doc = await signedFetchAP(url);
254
+ if (doc || !(this.config.membersOnly || []).includes(slug)) return doc;
255
+ return this.podFetchAP(url);
256
+ };
257
+ cat.intake.onCarried = (ev) => this.onCarried(cat, ev);
258
+ cat.intake.onReport = (activity, actor, opts) => cat.intake.queueModeration(activity, actor, opts);
259
+ // What happens to a post from somebody who has not joined: dropped, or
260
+ // held where a moderator will see it (the category's `replyPolicy`).
261
+ cat.intake.onStranger = (ev) => this.onStranger(cat, ev);
262
+ cat.intake.onCarriedEdit = (ev) => this.onCarriedEdit(cat, ev);
263
+ cat.intake.onCarriedGone = (ev) => this.onCarriedGone(cat, ev);
264
+ cat.intake.isModerationAskExtra = (a) => moderation.isForumAsk(cat, a);
265
+ cat.configured = () => true;
266
+ cat.log = this.log;
267
+ cat.c2s = new C2S({ agent: cat, log: this.log });
268
+ return cat;
269
+ }
270
+
271
+ // Who moderates, as the forum publishes it. A category writes its own list
272
+ // when its profile is published; this is the forum's, which the website
273
+ // reads, and nothing but a full publish used to rewrite it — so a moderator
274
+ // added while the forum ran did not appear until it was next started.
275
+ async republishAdministrators() {
276
+ const mods = this.config.moderators || [];
277
+ await publish.publishAdministrators(this.siteAgent, mods, { force: true });
278
+ // Every category's list is the forum's, written now rather than at the
279
+ // next start, and kept in the category's own settings so its queue and
280
+ // its actor agree with it.
281
+ for (const cat of this.categories) {
282
+ cat.config.moderators = mods;
283
+ cat.store.setConfig({ ...cat.store.getConfig(), moderators: mods });
284
+ await podFeatured.writeModerators(this.remote, cat.urls, orderedCollection(cat.urls.moderators, mods))
285
+ .catch(e => this.log(`moderators of ${cat.slug}: ${e.message}`));
286
+ }
287
+ }
288
+
289
+ // The forum's own actor: an Application that answers for the site, whose
290
+ // inbox is the one every category names. Its store is the forum's.
291
+ async buildSite() {
292
+ const { site, store } = this;
293
+ const gw = this.config.gateway;
294
+ const config = {
295
+ ...this.config, kind: 'application', root: this.site.root, remotePod: this.site.base,
296
+ ...(gw?.front ? { gateway: {
297
+ url: `${gw.front}/u/${this.config.handle}/ap/inbox/`, frontActor: `${gw.front}/u/${this.config.handle}/ap/actor`,
298
+ mode: gw.mode || 'trust', hmacSecret: gw.secrets?.[this.config.handle] || null,
299
+ } } : {}),
300
+ };
301
+ const keys = await resolveKeys(store, { localDir: null, actorId: site.actor, log: this.log });
302
+ const agent = { urls: site, store, config, remote: this.remote, log: this.log, configured: () => true };
303
+ agent.deliverer = new Deliverer({
304
+ store, rsaPrivate: keys.rsaPrivate, keyId: site.actor + '#main-key', actorId: site.actor,
305
+ edPrivate: keys.edPrivate, proofKeyId: assertionKeyId(site), log: this.log, passive: true,
306
+ });
307
+ agent.publisher = new Publisher({
308
+ config, remote: this.remote, store, deliverer: agent.deliverer,
309
+ publicKeyPem: keys.rsaPublicPem, assertionKey: keys.edPublicMultibase, log: this.log,
310
+ resolveActor: (u) => this.intake.fetchAP(u), probeFetch: this.probeFetch || null,
311
+ });
312
+ this.intake = new ForumIntake({
313
+ config, urls: site, remote: this.remote, store, deliverer: agent.deliverer, publisher: agent.publisher,
314
+ log: this.log, lease: this.lease, push: this.push, pollSeconds: this.pollSeconds,
315
+ archive: this.storageFor(site.home + 'inbox-archive/', (u, i) => this.remote.fetch(u, i)),
316
+ ownerPost: () => {},
317
+ }, this);
318
+ agent.intake = this.intake;
319
+ return agent;
320
+ }
321
+
322
+ // Which categories an activity is for: the ones it names, in its
323
+ // addressing or its object's, or whose topic it replies into. Pure
324
+ // addressing; the category's own intake does the verifying.
325
+ route(activity) {
326
+ const ids = new Set();
327
+ const add = (v) => { const id = idOf(v); if (id) ids.add(id); };
328
+ const obj = activity?.object && typeof activity.object === 'object' ? activity.object : null;
329
+ for (const f of ['to', 'cc', 'audience']) {
330
+ arr(activity?.[f]).forEach(add);
331
+ if (obj) arr(obj[f]).forEach(add);
332
+ }
333
+ add(activity?.object); add(activity?.target); add(activity?.origin);
334
+ if (obj) { add(obj.context); add(obj.inReplyTo); add(obj.target); add(obj.object); }
335
+ const out = [];
336
+ for (const cat of this.categories) {
337
+ const u = cat.urls;
338
+ const mine = new Set([u.actor, u.followers, u.moderators, u.featured, u.topics, u.outbox]);
339
+ let hit = [...ids].some(id => mine.has(id) || id.startsWith(u.topicContainer) || id.startsWith(u.notes));
340
+ if (!hit && obj?.inReplyTo) hit = !!topics.topicOf(cat.store, idOf(obj.inReplyTo));
341
+ if (hit) out.push(cat);
342
+ }
343
+ return out;
344
+ }
345
+
346
+ namesSite(activity) {
347
+ const s = this.site;
348
+ const ids = [activity?.object, activity?.target, ...arr(activity?.to), ...arr(activity?.cc)].map(idOf).filter(Boolean);
349
+ return ids.some(id => id === s.actor || id === s.followers);
350
+ }
351
+
352
+ // After a category carried a post: place it in its topic, keep a copy for
353
+ // readers, and publish what changed.
354
+ async onCarried(cat, { noteId, sent }) {
355
+ let note = cat.intake.recentNotes.get(noteId) || null;
356
+ cat.intake.recentNotes.delete(noteId);
357
+ if (!note) note = await cat.intake.fetchAP(noteId);
358
+ if (!note) { this.log(`carried ${noteId} but could not read it for the topic`); return; }
359
+ const tid = await topics.assign({ store: cat.store, urls: cat.urls, fetchAP: (u) => cat.intake.fetchAP(u) }, note, sent);
360
+ // A locked topic takes no more: the post leaves it again and the carry is
361
+ // unsaid, so members' servers do not keep what the forum does not.
362
+ if (moderation.isLocked(cat, tid) && !topics.list(cat.store).find(t => t.tid === tid && t.op === noteId)) {
363
+ topics.remove(cat.store, tid, noteId);
364
+ await cat.intake.retract(noteId).catch(e => this.log(`retract ${noteId}: ${e.message}`));
365
+ this.log(`${cat.slug}: ${noteId} not placed — topic ${tid} is locked`);
366
+ return;
367
+ }
368
+ await publish.cachePost(cat, note, { topic: cat.urls.topic(tid), replies: 0 });
369
+ this.noteLatest(cat, note);
370
+ const answered = idOf(note.inReplyTo);
371
+ if (answered) await this.countReplies(cat, tid, answered).catch(e => this.log(`replies of ${answered}: ${e.message}`));
372
+ // The author's card, from the actor the intake already holds; fetched
373
+ // once when it does not, so the website can name them.
374
+ const author = idOf([].concat(note.attributedTo || [])[0]);
375
+ if (author) {
376
+ const held = cat.store.getActors?.()[author];
377
+ const doc = held ? { id: author, ...held } : await cat.intake.fetchAP(author).catch(() => null);
378
+ if (doc) await publish.cacheAuthor(cat, doc).catch(e => this.log(`author card for ${author}: ${e.message}`));
379
+ }
380
+ await publish.publishTopic(cat, tid);
381
+ await publish.publishTopicIndex(cat);
382
+ await publish.publishLatest(this.siteAgent);
383
+ this.log(`${cat.slug}: ${noteId} in topic ${tid}`);
384
+ }
385
+
386
+ // Take addresses at a Gateway: one row for the forum and one per category,
387
+ // each a handle at the front, all writing their mail into the forum's one
388
+ // inbox. The pod session proves the pod; no password leaves this machine.
389
+ // The secrets come back once and are kept in the forum's config; every
390
+ // actor is republished with its front ids on the next start.
391
+ async attach({ front, attachOne = null }) {
392
+ const origin = String(front).replace(/\/$/u, '');
393
+ const cred = this.readCredential();
394
+ if (!cred || !this.store || !this.config) throw new Error('connect first');
395
+ const plain = forumUrls(cred.remotePod, cred.root || ROOT);
396
+ const post = attachOne || (async (body) => {
397
+ const res = await this.remote.session.fetch(`${origin}/api/attach`, {
398
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
399
+ });
400
+ const d = await res.json().catch(() => ({}));
401
+ if (res.status !== 201 || !d.hmacSecret) throw new Error(`attach ${body.handle} at ${origin}: HTTP ${res.status}${d.error ? ' ' + d.error : ''}`);
402
+ return d;
403
+ });
404
+ const secrets = {};
405
+ const rows = [
406
+ { handle: this.config.handle, podHome: plain.home, actorUrl: plain.actor, kind: 'application' },
407
+ ...(this.config.categories || []).map(c => ({ handle: c.slug, podHome: plain.category(c.slug).home,
408
+ actorUrl: plain.category(c.slug).actor, kind: 'group' })),
409
+ ];
410
+ for (const row of rows) {
411
+ const d = await post({ ...row, fronted: true, inboxUrl: plain.inbox });
412
+ secrets[row.handle] = String(d.hmacSecret);
413
+ this.log(`attached @${row.handle}@${new URL(origin).host}`);
414
+ }
415
+ this.store.setConfig({ ...this.store.getConfig(), gateway: { front: origin, mode: 'trust', secrets }, republish: true });
416
+ await this.store.flush();
417
+ return { front: origin, handles: rows.map(r => r.handle) };
418
+ }
419
+
420
+ // One category's handle at the Gateway, claimed when a moderator creates
421
+ // that category from the settings page and at no other time. A name on a
422
+ // Gateway is public and only its admin can take one back, so nothing here
423
+ // claims names on its own account — not at a start, not on a repair, not
424
+ // for a category that arrived any other way.
425
+ async claimHandle(slug) {
426
+ const gw = this.config.gateway;
427
+ if (!gw?.front) return null; // not fronted: nothing to claim
428
+ if (gw.secrets?.[slug]) return null; // already has one
429
+ const cred = this.readCredential();
430
+ const plain = forumUrls(cred.remotePod, cred.root || ROOT);
431
+ const urls = plain.category(slug);
432
+ const origin = String(gw.front).replace(/\/$/u, '');
433
+ const res = await this.remote.session.fetch(`${origin}/api/attach`, {
434
+ method: 'POST', headers: { 'content-type': 'application/json' },
435
+ body: JSON.stringify({ handle: slug, podHome: urls.home, actorUrl: urls.actor, kind: 'group',
436
+ fronted: true, inboxUrl: plain.inbox }),
437
+ });
438
+ const d = await res.json().catch(() => ({}));
439
+ if (res.status !== 201 || !d.hmacSecret) {
440
+ // A name already taken, or a Gateway that refuses: the category is
441
+ // hosted all the same, with no handle until someone sorts it out.
442
+ this.log(`no handle for @${slug}: HTTP ${res.status}${d.error ? ' ' + d.error : ''}`);
443
+ return null;
444
+ }
445
+ const cfg = this.store.getConfig();
446
+ this.store.setConfig({ ...cfg, gateway: { ...cfg.gateway, secrets: { ...(cfg.gateway?.secrets || {}), [slug]: String(d.hmacSecret) } }, republish: true });
447
+ this.config = this.store.getConfig();
448
+ await this.store.flush().catch(() => {});
449
+ this.log(`claimed @${slug}@${new URL(origin).host}`);
450
+ return slug;
451
+ }
452
+
453
+ // A queued moderator's ask, applied by the operator.
454
+ // Topics written into a sub-folder before they were flat: read each one at
455
+ // its old address and put it where the state can see it.
456
+ async carryOldTopics(cat) {
457
+ for (const t of topics.list(cat.store)) {
458
+ if (topics.get(cat.store, t.tid)) continue;
459
+ const old = await this.remote.getJson(cat.urls.state + topics.topicDocOld(t.tid)).catch(() => null);
460
+ if (!old) continue;
461
+ cat.store.write(topics.topicDoc(t.tid), old);
462
+ this.log(`topic ${t.tid} carried into the state`);
463
+ }
464
+ await cat.store.flush().catch(() => {});
465
+ }
466
+
467
+ // An author edited a post of theirs that we hold: the copy the website
468
+ // reads is rewritten from the note as verified at its origin, and a changed
469
+ // title is the topic's title when that post opened it.
470
+ async onCarriedEdit(cat, { noteId, note }) {
471
+ // The copy is rewritten from the note as verified at its origin; the
472
+ // counts the forum keeps on it are put back from what the forum knows.
473
+ const v = cat.store.read('votes.json', {})[noteId];
474
+ const held = topics.topicOf(cat.store, noteId);
475
+ const replies = held ? (topics.get(cat.store, held)?.posts || []).filter(p => p.inReplyTo === noteId).length : null;
476
+ await publish.cachePost(cat, note, { topic: held ? cat.urls.topic(held) : null, replies,
477
+ likes: (Array.isArray(v) ? v : v?.up || []).length, dislikes: (Array.isArray(v) ? [] : v?.down || []).length });
478
+ await publish.publishLatest(this.siteAgent);
479
+ const tid = topics.topicOf(cat.store, noteId);
480
+ if (!tid) return;
481
+ await publish.publishTopic(cat, tid, { force: true });
482
+ }
483
+
484
+ // An author deleted one: it leaves the topic, its copy becomes a tombstone
485
+ // (FEP-4f05), and a topic with nothing left in it goes too. The carry has
486
+ // already been taken back by the group itself.
487
+ async onCarriedGone(cat, { noteId }) {
488
+ const tid = topics.topicOf(cat.store, noteId);
489
+ // Whatever it answered has one fewer answer now.
490
+ const answered = tid ? idOf(topics.get(cat.store, tid)?.posts?.find(p => p.id === noteId)?.inReplyTo) : null;
491
+ this.dropLatest(cat, noteId);
492
+ await publish.tombstoneCached(cat, noteId);
493
+ await publish.publishLatest(this.siteAgent);
494
+ if (!tid) return;
495
+ topics.remove(cat.store, tid, noteId);
496
+ const left = topics.get(cat.store, tid);
497
+ if (!left?.posts?.length) {
498
+ await moderation.deleteTopic(cat, tid).catch(e => this.log(`empty topic ${tid}: ${e.message}`));
499
+ return;
500
+ }
501
+ await publish.publishTopic(cat, tid, { force: true });
502
+ await publish.publishTopicIndex(cat, { force: true });
503
+ if (answered) await this.countReplies(cat, tid, answered).catch(e => this.log(`replies of ${answered}: ${e.message}`));
504
+ }
505
+
506
+ // Queued asks from listed moderators that can be verified at their own
507
+ // origin are applied; everything else waits for a person.
508
+ async applyVerifiedAsks() {
509
+ await this.applySettingsAsks().catch(e => this.log(`settings: ${e.message}`));
510
+ for (const cat of this.categories) {
511
+ const q = cat.store.read('modqueue.json', []);
512
+ for (const entry of [...q]) {
513
+ if (!(this.config.moderators || []).includes(entry.moderator)) continue;
514
+ const id = entry.activity?.id;
515
+ if (typeof id !== 'string') continue;
516
+ if (!cat.intake.sameIdentity(id, entry.moderator)) continue;
517
+ const doc = await cat.intake.fetchAP(id).catch(() => null);
518
+ if (!doc || doc.type !== entry.type) continue;
519
+ if (idOf(doc.actor) !== entry.moderator) continue;
520
+ try {
521
+ // Apply the copy fetched at its origin, not the one delivered: the
522
+ // queue keeps a trimmed activity — enough to say what was asked for,
523
+ // not enough to carry a new name — and the origin's copy is the one
524
+ // its author actually published.
525
+ const q2 = cat.store.read('modqueue.json', []);
526
+ const i = q2.findIndex(x => x.id === entry.id);
527
+ if (i >= 0) { q2[i] = { ...q2[i], activity: doc, verified: true }; cat.store.write('modqueue.json', q2); }
528
+ await this.applyModeration(cat.slug, entry.id);
529
+ this.log(`applied ${entry.type} from ${entry.moderator}`);
530
+ } catch (e) {
531
+ this.log(`ask ${entry.id}: ${e.message}`);
532
+ // It stays in the queue and carries why it did not take, so a
533
+ // moderator sees an ask that failed rather than one that silently
534
+ // did nothing. The next sweep tries it again.
535
+ const q3 = cat.store.read('modqueue.json', []);
536
+ const j = q3.findIndex(x => x.id === entry.id);
537
+ if (j >= 0) {
538
+ q3[j] = { ...q3[j], failed: e.message, failedAt: new Date().toISOString() };
539
+ cat.store.write('modqueue.json', q3);
540
+ }
541
+ }
542
+ }
543
+ }
544
+ }
545
+
546
+ membersOf(cat) { return access.membersOf(this, cat); }
547
+ readersOf(cat) { return access.readersOf(this, cat); }
548
+ podFetchAP(url) { return access.podFetchAP(this, url); }
549
+ dropUnreadableFollowers(cat) { return access.dropUnreadableFollowers(this, cat); }
550
+
551
+ // A pin that holds across the whole forum, not just one category: the site
552
+ // actor has a featured collection of its own, and this is it.
553
+ async sitePin(topicId, on) {
554
+ const { orderedCollection } = await import('fedipod/core/wire.mjs');
555
+ const collection = await import('fedipod/pod/collection.mjs');
556
+ const held = this.store.read('sitepins.json', []).filter(id => id !== topicId);
557
+ const ids = on ? [topicId, ...held] : held;
558
+ this.store.write('sitepins.json', ids);
559
+ await collection.writeFlat(this.remote, this.site.featured,
560
+ orderedCollection(this.site.featured, ids), { publicRead: true });
561
+ return { pinned: ids };
562
+ }
563
+
564
+ // A post from someone who is not a member. 'members' drops it, which is
565
+ // what a group does; 'review' holds it for a moderator, which is what a
566
+ // forum usually wants; 'open' is not offered — a forum that carries
567
+ // anything addressed to it is a forum for spam.
568
+ async onStranger(cat, { noteId, actor, activity }) {
569
+ const how = this.config.replyPolicy || 'open';
570
+ // A private category's membership IS the right to read it, and only a
571
+ // moderator gives that; a forum set to review holds a stranger either way.
572
+ const closed = (this.config.membersOnly || []).includes(cat.slug);
573
+ const hold = () => {
574
+ cat.intake.queueModeration(
575
+ { type: 'Create', actor, object: noteId, ...(activity?.id ? { id: activity.id } : {}) },
576
+ actor, { trusted: false },
577
+ );
578
+ return true;
579
+ };
580
+ if (how === 'review' || closed) return hold();
581
+ return (await this.joinOnPost(cat, actor)) ? false : hold();
582
+ }
583
+
584
+ // Posting into an open category joins it, which is what the website's own
585
+ // posting does before it posts: a post from elsewhere arrives with no Follow
586
+ // in front of it, and holding it for a moderator made every first post from
587
+ // another server wait. There is nothing to Accept — no Follow was sent — so
588
+ // the member is recorded and the category is published again.
589
+ async joinOnPost(cat, actor) {
590
+ try {
591
+ const contacts = cat.store.getContacts();
592
+ if (contacts.followers.some(f => f.actor === actor)) return true;
593
+ const doc = await cat.intake.fetchAP(actor);
594
+ if (!doc?.inbox) { this.log(`${actor} posted but its actor names no inbox — held instead`); return false; }
595
+ contacts.followers.push({ actor, inbox: doc.inbox, sharedInbox: doc.endpoints?.sharedInbox });
596
+ cat.store.setContacts(contacts);
597
+ cat.store.addNotification({ type: 'follow', actor });
598
+ await cat.intake.republish({ followers: true });
599
+ this.log(`joined by posting: ${actor} → @${cat.slug}`);
600
+ return true;
601
+ } catch (e) {
602
+ this.log(`could not join ${actor} on posting (${e.message}) — held instead`);
603
+ return false;
604
+ }
605
+ }
606
+
607
+ // Requests about the forum itself: a category created, a name changed, who
608
+ // moderates, who may read. Checked at the asker's own pod exactly as a
609
+ // moderator's other asks are, then applied.
610
+ async applySettingsAsks() {
611
+ const q = this.store.read('modqueue.json', []);
612
+ for (const entry of [...q]) {
613
+ if (!(this.config.moderators || []).includes(entry.moderator)) continue;
614
+ const id = entry.activity?.id;
615
+ if (typeof id !== 'string' || !this.intake.sameIdentity(id, entry.moderator)) continue;
616
+ const doc = await this.intake.fetchAP(id).catch(() => null);
617
+ if (!doc || doc.type !== entry.type || idOf(doc.actor) !== entry.moderator) continue;
618
+ try {
619
+ const done = await settings.applySettings(this, doc);
620
+ // The one place a name is claimed: a moderator asked for this
621
+ // category by name a moment ago.
622
+ if (done.category && doc.type === 'Create') {
623
+ await this.claimHandle(done.category).catch(e => this.log(`handle for ${done.category}: ${e.message}`));
624
+ }
625
+ this.store.write('modqueue.json', this.store.read('modqueue.json', []).filter(e => e.id !== entry.id));
626
+ await this.store.flush().catch(() => {});
627
+ await this.noteModLog({ ...entry, activity: doc }, done);
628
+ this.log(`applied ${entry.type} from ${entry.moderator}: ${JSON.stringify(done)}`);
629
+ // A forum that has just changed shape publishes itself again.
630
+ if (Object.keys(done).length) await this.reshape();
631
+ } catch (e) { this.log(`settings ask ${entry.id}: ${e.message}`); }
632
+ }
633
+ }
634
+
635
+ // What a change of shape needs: any category named in the config that is
636
+ // not being hosted yet is built, and everything is published again.
637
+ async reshape() {
638
+ for (const c of this.config.categories || []) {
639
+ if (this.categories.some(x => x.slug === c.slug)) continue;
640
+ const cat = await this.buildCategory(c);
641
+ this.categories.push(cat);
642
+ this.log(`category added: ${c.slug}`);
643
+ }
644
+ if (this.site.toPod && this.remote.setUrlMap) this.remote.setUrlMap((u) => this.toPod(u));
645
+ await this.publishAll().catch(e => this.log(`after a change of shape: ${e.message}`));
646
+ }
647
+
648
+ // The WebID behind a Fediverse actor, when its pod says so. A FediPod
649
+ // account's WebID lists the actor as an account of the person; that link,
650
+ // read at the pod, is what makes it safe to grant them anything.
651
+ async webIdOf(actor) {
652
+ const doc = await this.intake.fetchAP(actor).catch(() => null);
653
+ const said = [doc?.webId, ...[].concat(doc?.alsoKnownAs || [])].find(v => typeof v === 'string' && v.includes('#'));
654
+ if (said) return said;
655
+ // Otherwise the pod the actor is published on, whose card is where a
656
+ // FediPod account records itself.
657
+ const home = new URL(actor).origin;
658
+ for (const card of [`${home}/profile/card`, `${home}/profile/card#me`]) {
659
+ const r = await fetch(card.replace(/#me$/u, ''), { headers: { accept: 'text/turtle' } }).catch(() => null);
660
+ if (!r?.ok) continue;
661
+ const text = await r.text().catch(() => '');
662
+ if (text.includes(actor)) return `${home}/profile/card#me`;
663
+ }
664
+ return null;
665
+ }
666
+
667
+ // The moderators' queue, written where only they can read it: what each
668
+ // category is holding, and who asked for what.
669
+ // What was done, and by whom: a record for the moderators, beside their
670
+ // queue and under the same rule.
671
+ async noteModLog(entry, outcome) {
672
+ const log = this.store.read('modlog.json', []);
673
+ log.unshift({
674
+ at: new Date().toISOString(), type: entry.type, by: entry.moderator,
675
+ object: typeof entry.activity?.object === 'string' ? entry.activity.object : entry.activity?.object?.id || null,
676
+ outcome: outcome && typeof outcome === 'object' ? Object.keys(outcome).join(',') : String(outcome ?? ''),
677
+ });
678
+ this.store.write('modlog.json', log.slice(0, 500));
679
+ await this.remote.putJson(this.site.mod + 'log.json', { at: new Date().toISOString(), rows: log.slice(0, 500) }, 'application/json')
680
+ .catch(e => this.log(`mod log: ${e.message}`));
681
+ }
682
+
683
+ async publishModQueue() {
684
+ // The rule follows the configuration: moderators come and go, and the
685
+ // container was provisioned once, long before this one was named. Stated
686
+ // when the moderators change, and read rather than rewritten at start.
687
+ const mods = publish.digestOf(this.config.moderatorWebIds || []);
688
+ if (this._modRule !== mods) {
689
+ await this.remote.setAcl(this.site.mod, [], { readAgents: this.config.moderatorWebIds || [], ifChanged: true })
690
+ .catch(e => this.log(`queue rule: ${e.message}`));
691
+ this._modRule = mods;
692
+ }
693
+ const rows = [];
694
+ for (const cat of this.categories) {
695
+ for (const e of cat.store.read('modqueue.json', [])) {
696
+ const object = typeof e.activity?.object === 'string' ? e.activity.object : e.activity?.object?.id || null;
697
+ // Who wrote the thing being complained about, so a moderator acting
698
+ // on a report acts on its author and not on whoever reported it.
699
+ const about = object ? (cat.store.getStatuses().find(st => st.noteId === object)?.actor || null) : null;
700
+ rows.push({ category: cat.slug, id: e.id, type: e.type, by: e.moderator, at: e.at,
701
+ object, about, why: e.activity?.content || null, verified: !!e.verified,
702
+ ...(e.failed ? { failed: e.failed, failedAt: e.failedAt || null } : {}) });
703
+ }
704
+ for (const r of cat.store.getRequests?.() || []) {
705
+ rows.push({ category: cat.slug, id: 'join:' + r.actor, type: 'Join request', by: r.actor, at: r.at || null, object: r.actor });
706
+ }
707
+ for (const p of cat.store.getPending?.() || []) {
708
+ rows.push({ category: cat.slug, id: 'pending:' + p.noteId, type: 'Held', by: p.actor || null, at: p.at || null, object: p.noteId });
709
+ }
710
+ }
711
+ rows.sort((a, b) => String(b.at).localeCompare(String(a.at)));
712
+ // Swept every minute, and on a quiet forum every sweep found the same
713
+ // rows: the timestamp alone made each one a write. The rows decide.
714
+ const digest = publish.digestOf(rows);
715
+ if (this._modQueue === digest) return rows.length;
716
+ await this.remote.putJson(this.site.mod + 'queue.json',
717
+ { at: new Date().toISOString(), rows }, 'application/json');
718
+ this._modQueue = digest;
719
+ return rows.length;
720
+ }
721
+
722
+ // One vote per person per post, either way. Up is AS2's `likes` on the
723
+ // post's copy, which the website reads with the post. Down has no property
724
+ // in AS2 and none is invented for it: it is published as a collection of
725
+ // its own beside the copy, named for the post it counts.
726
+ //
727
+ // A record written before there were downvotes is a bare list of who voted
728
+ // for the post; it is read as the up list and written back in both parts.
729
+ async countVote({ post, actor, way }) {
730
+ if (!post || !actor) return false;
731
+ const cat = this.categories.find(c => !!topics.topicOf(c.store, post));
732
+ if (!cat) return false;
733
+ const votes = cat.store.read('votes.json', {});
734
+ const was = votes[post];
735
+ const up = new Set(Array.isArray(was) ? was : (was?.up || []));
736
+ const down = new Set(Array.isArray(was) ? [] : (was?.down || []));
737
+ // Changing your mind is not two votes: whichever way it goes now, the
738
+ // other way lets go of you.
739
+ up.delete(actor);
740
+ down.delete(actor);
741
+ if (way === 'up') up.add(actor);
742
+ if (way === 'down') down.add(actor);
743
+ votes[post] = { up: [...up], down: [...down] };
744
+ cat.store.write('votes.json', votes);
745
+ const copy = await this.remote.getJson(cat.urls.cached(post)).catch(() => null);
746
+ if (copy && copy.type !== 'Tombstone') {
747
+ await publish.cachePost(cat, copy, { likes: up.size, dislikes: down.size });
748
+ await publish.publishDislikes(cat, post, down.size).catch(e => this.log(`downvotes on ${post}: ${e.message}`));
749
+ await publish.publishLatest(this.siteAgent).catch(() => {});
750
+ }
751
+ this.log(`${way === 'none' ? 'vote withdrawn' : `vote ${way}`} on ${post} — ${up.size} up, ${down.size} down`);
752
+ return true;
753
+ }
754
+
755
+ // Answers to one post, counted in the topic that holds it, and written
756
+ // into the copy the website reads.
757
+ async countReplies(cat, tid, postId) {
758
+ const doc = topics.get(cat.store, tid);
759
+ if (!doc) return;
760
+ const n = (doc.posts || []).filter(p => p.inReplyTo === postId).length;
761
+ const copy = await this.remote.getJson(cat.urls.cached(postId)).catch(() => null);
762
+ if (!copy || copy.type === 'Tombstone') return;
763
+ if (Number(copy.replies?.totalItems) === n) return;
764
+ await publish.cachePost(cat, copy, { replies: n });
765
+ }
766
+
767
+ // The forum's own index of its newest posts, across every category.
768
+ noteLatest(cat, note) {
769
+ const copy = cat.urls.cached(note.id);
770
+ const at = note.published || new Date().toISOString();
771
+ const rows = this.store.read('latest.json', []).filter(e => e.copy !== copy);
772
+ rows.push({ copy, at });
773
+ rows.sort((a, b) => String(b.at).localeCompare(String(a.at)));
774
+ this.store.write('latest.json', rows.slice(0, publish.LATEST_MAX));
775
+ }
776
+
777
+ // Everything the forum already holds, read back out of the topics, for an
778
+ // index that did not exist when those posts arrived.
779
+ // Copies kept before the forum recorded which topic they were in: the
780
+ // topic knows, so the copy is rewritten from it.
781
+ async stampOldCopies(cat) {
782
+ for (const entry of topics.list(cat.store)) {
783
+ const doc = topics.get(cat.store, entry.tid);
784
+ for (const p of doc?.posts || []) {
785
+ const url = cat.urls.cached(p.id);
786
+ const copy = await this.remote.getJson(url).catch(() => null);
787
+ if (!copy || copy.type === 'Tombstone') continue;
788
+ const answers = (doc?.posts || []).filter(x => x.inReplyTo === p.id).length;
789
+ if (copy.context && Number(copy.replies?.totalItems) === answers) continue;
790
+ await publish.cachePost(cat, copy, { topic: cat.urls.topic(entry.tid), replies: answers });
791
+ this.log(`${p.id}: its topic and its answers recorded`);
792
+ }
793
+ }
794
+ }
795
+
796
+ rebuildLatest() {
797
+ const rows = [];
798
+ for (const cat of this.categories) {
799
+ for (const entry of topics.list(cat.store)) {
800
+ const doc = topics.get(cat.store, entry.tid);
801
+ for (const p of doc?.posts || []) rows.push({ copy: cat.urls.cached(p.id), at: p.published || entry.created || '' });
802
+ }
803
+ }
804
+ rows.sort((a, b) => String(b.at).localeCompare(String(a.at)));
805
+ this.store.write('latest.json', rows.slice(0, publish.LATEST_MAX));
806
+ return rows.length;
807
+ }
808
+
809
+ dropLatest(cat, noteId) {
810
+ const copy = cat.urls.cached(noteId);
811
+ this.store.write('latest.json', this.store.read('latest.json', []).filter(e => e.copy !== copy));
812
+ }
813
+
814
+ async applyModeration(slug, entryId) {
815
+ const cat = this.categories.find(c => c.slug === slug);
816
+ if (!cat) throw new Error(`no such category: ${slug}`);
817
+ const q = cat.store.read('modqueue.json', []);
818
+ const entry = q.find(e => e.id === entryId);
819
+ if (!entry) throw new Error(`no such queue entry: ${entryId}`);
820
+ const r = await moderation.applyForumModeration(this, cat, entry);
821
+ cat.store.write('modqueue.json', cat.store.read('modqueue.json', []).filter(e => e.id !== entryId));
822
+ await this.noteModLog(entry, r);
823
+ await this.publishModQueue().catch(e => this.log(`queue: ${e.message}`));
824
+ return r;
825
+ }
826
+
827
+ // Publish every actor whose document is not yet up, and the forum's lists.
828
+ async publishAll({ force = false } = {}) {
829
+ // Cleared at the END, not here: a publish that dies part-way used to lower
830
+ // the flag on its way in, so the next start thought the work was done and
831
+ // the forum kept the name and ids it was told to replace.
832
+ const asked = !!this.config.republish;
833
+ if (asked) force = true;
834
+ // Containers and their rules are rewritten only when who may read what
835
+ // has changed. A republish otherwise leaves them alone: rewriting dozens
836
+ // of rules on every attempt is what a rate-limited pod refuses, and a
837
+ // refusal used to mean the request was never cleared and never finished.
838
+ const redo = !!this.config.reprovision;
839
+ // Containers and their access rules are written once, not on every start:
840
+ // a forum that re-wrote them each time it came up spent dozens of pod
841
+ // writes saying what the pod already said, and a busy pod answered 429.
842
+ const made = this.store.read('provisioned.json', {});
843
+ if (redo || !made.forum) {
844
+ await provisionForum(this.remote, this.site, { moderatorWebIds: this.config.moderatorWebIds || [] });
845
+ this.store.write('provisioned.json', { ...made, forum: new Date().toISOString() });
846
+ }
847
+ for (const cat of this.categories) {
848
+ const done = this.store.read('provisioned.json', {});
849
+ if (redo || !done[cat.slug]) {
850
+ await provisionCategory(this.remote, cat.urls, { memberWebIds: this.membersOf(cat) });
851
+ this.store.write('provisioned.json', { ...done, [cat.slug]: new Date().toISOString() });
852
+ }
853
+ // Who the category's posts are written for, and letting go of anyone it
854
+ // cannot let read. Both are cheap when nothing changed: the list is
855
+ // written only when its digest moved, and the sweep only for a private
856
+ // category.
857
+ await publish.publishMembers(cat, this.readersOf(cat), { force: redo })
858
+ .catch(e => this.log(`the reader list for ${cat.slug}: ${e.message}`));
859
+ await this.dropUnreadableFollowers(cat)
860
+ .catch(e => this.log(`letting go of ${cat.slug}'s strangers: ${e.message}`));
861
+ await this.carryOldTopics(cat);
862
+ await this.stampOldCopies(cat).catch(e => this.log(`stamping ${cat.slug}: ${e.message}`));
863
+ const seen = cat.store.read('published.json', {});
864
+ if (force || !seen.actorDigest) {
865
+ await cat.publisher.publishProfile({ force });
866
+ // The category's own inbox container exists because the group's
867
+ // publisher made it; nothing drains it, so nobody may append to it.
868
+ await podInbox.setPosture(this.remote, cat.urls, 'closed');
869
+ // The profile publish above wrote the category's featured list as its
870
+ // pinned posts, of which a forum has none: the pinned topics go back.
871
+ await publish.publishPinnedTopics(cat);
872
+ await publish.publishTopicIndex(cat, { force: true });
873
+ for (const t of topics.list(cat.store)) {
874
+ // A topic the state cannot produce must not stop the forum coming
875
+ // up: say so and publish the rest.
876
+ if (!topics.get(cat.store, t.tid)) { this.log(`topic ${t.tid} has no record — skipped`); continue; }
877
+ await publish.publishTopic(cat, t.tid, { force: true });
878
+ }
879
+ }
880
+ }
881
+ // A reprovision states every rule again, the forum's flat lists' among
882
+ // them. Their rule is written with their first publish, so the record of
883
+ // that publish is forgotten here and the publish below writes it.
884
+ if (redo) this.store.write('published.json', { ...this.store.read('published.json', {}), categories: null, latest: null, administrators: null });
885
+ const seen = this.store.read('published.json', {});
886
+ if (force || !seen.actorDigest) await this.siteAgent.publisher.publishProfile({ force });
887
+ await publish.publishCategories(this.siteAgent, this.categories.map(c => c.urls.actor), { force });
888
+ if (!this.store.read('latest.json', []).length) {
889
+ const n = this.rebuildLatest();
890
+ if (n) this.log(`latest: ${n} post(s) read back out of the topics`);
891
+ }
892
+ await publish.publishLatest(this.siteAgent, { force });
893
+ await publish.publishAdministrators(this.siteAgent, this.config.moderators || [], { force });
894
+ if (asked) {
895
+ this.store.setConfig({ ...this.store.getConfig(), republish: false, reprovision: false });
896
+ this.config = this.store.getConfig();
897
+ await this.store.flush().catch(() => {});
898
+ }
899
+ }
900
+
901
+ async startActive() {
902
+ this.viewer = false;
903
+ clearInterval(this.refreshTimer);
904
+ this.lease.onLost = () => this.demote();
905
+ this.lease.startRenewal();
906
+ for (const cat of this.categories) cat.deliverer.startQueue();
907
+ this.siteAgent.deliverer.startQueue();
908
+ await this.publishAll();
909
+ this.intake.afterDrain = () => this.applyVerifiedAsks()
910
+ .then(() => this.publishModQueue())
911
+ .catch(e => this.log(`asks: ${e.message}`));
912
+ await this.intake.start();
913
+ await publish.publishHeartbeat(this.siteAgent).catch(e => this.log(`heartbeat: ${e.message}`));
914
+ this.heartbeatTimer = setInterval(() => {
915
+ publish.publishHeartbeat(this.siteAgent).catch(e => this.log(`heartbeat: ${e.message}`));
916
+ }, HEARTBEAT_MS);
917
+ // An ask is checked at its asker's pod before it is acted on, and that
918
+ // check used to happen only when the inbox was drained. A moderator asking
919
+ // for something on a quiet forum therefore waited for the next person to
920
+ // post — and if the document was a moment from being readable when the ask
921
+ // arrived, it waited for the one after that. So it is swept on its own
922
+ // clock as well.
923
+ this.asksTimer = setInterval(() => {
924
+ this.applyVerifiedAsks()
925
+ .then(() => this.publishModQueue())
926
+ .catch(e => this.log(`asks: ${e.message}`));
927
+ }, ASK_SWEEP_MS);
928
+ this.asksTimer.unref?.();
929
+ this.heartbeatTimer.unref?.();
930
+ this.log(`hosting ${this.config.handle}: ${this.categories.map(c => '@' + c.slug).join(', ')}`);
931
+ }
932
+
933
+ // Watching: refresh what is held, and act the moment the lease frees.
934
+ startViewer() {
935
+ this.viewer = true;
936
+ this.refreshTimer = setInterval(() => this.tryPromote().catch(e => this.log(`viewer: ${e.message}`)),
937
+ Math.round(VIEWER_REFRESH_MS * (0.85 + Math.random() * 0.3)));
938
+ this.refreshTimer.unref?.();
939
+ }
940
+
941
+ async tryPromote() {
942
+ if (!this.viewer) return true;
943
+ if (await this.lease.acquire()) {
944
+ this.log('lease freed — this device now hosts the forum');
945
+ await this.store.load({ force: true }).catch(() => {});
946
+ for (const cat of this.categories) await cat.store.load({ force: true }).catch(() => {});
947
+ try {
948
+ await this.startActive();
949
+ } catch (e) {
950
+ // A promotion that dies part-way left the forum neither hosting nor
951
+ // watching: the timer was cleared on the way in and the lease was
952
+ // held by a device doing nothing. Give both back.
953
+ this.log(`could not start hosting (${e.message}) — watching again`);
954
+ for (let i = 0; i < 4; i++) {
955
+ try { await this.lease.release(); break; } catch { await new Promise(r => setTimeout(r, 8000)); }
956
+ }
957
+ this.startViewer();
958
+ return false;
959
+ }
960
+ return true;
961
+ }
962
+ await this.store.load().catch(() => {});
963
+ return false;
964
+ }
965
+
966
+ demote() {
967
+ if (this.viewer) return;
968
+ this.log('another device took the forum over — watching');
969
+ this.intake?.stop();
970
+ for (const cat of this.categories) cat.deliverer.stop();
971
+ this.siteAgent?.deliverer.stop();
972
+ clearInterval(this.heartbeatTimer);
973
+ clearInterval(this.asksTimer);
974
+ this.lease?.stopRenewal();
975
+ this.startViewer();
976
+ }
977
+
978
+ async stop() {
979
+ clearInterval(this.refreshTimer);
980
+ clearInterval(this.heartbeatTimer);
981
+ clearInterval(this.asksTimer);
982
+ this.intake?.stop();
983
+ for (const cat of this.categories) cat.deliverer.stop();
984
+ this.siteAgent?.deliverer.stop();
985
+ this.lease?.stopRenewal();
986
+ await Promise.allSettled([
987
+ this.store?.flush(), ...this.categories.map(c => c.store.flush()),
988
+ this.viewer ? Promise.resolve() : this.lease?.release(),
989
+ ]);
990
+ }
991
+ }