fedipod-bb 0.2.0 → 0.3.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.
@@ -20,6 +20,10 @@
20
20
  // per category. Deliveries arrive verified at the front and are written
21
21
  // into the forum's inbox; every actor is republished with its front ids
22
22
  // on the next start.
23
+ // fedipod-bb keep --home DIR --front https://fedipod.net
24
+ // Let that Gateway run the forum when nothing else does: the pod's rules
25
+ // name its keeper from here on, and it places posts as they land. `unkeep`
26
+ // takes the name out again.
23
27
 
24
28
  import fs from 'node:fs';
25
29
  import path from 'node:path';
@@ -102,12 +106,22 @@ if (cmd === 'credential') {
102
106
  const r = await agent.attach({ front });
103
107
  console.log(`attached at ${r.front}: ${r.handles.map(h => '@' + h).join(', ')} — start the forum to publish its new addresses`);
104
108
  process.exit(0);
109
+ } else if (cmd === 'keep' || cmd === 'unkeep') {
110
+ const front = flag('front');
111
+ if (!front) { console.error('--front <https://gateway-origin> is required'); process.exit(2); }
112
+ const agent = new ForumAgent({ home, log });
113
+ if (!await agent.connect({ act: false })) { console.error('nothing to keep — run init first'); process.exit(1); }
114
+ const r = await agent.keep({ front, on: cmd === 'keep' });
115
+ console.log(cmd === 'keep'
116
+ ? `${front} keeps this forum (${r.handles.map(h => '@' + h).join(', ')}) as ${r.keeper}`
117
+ : `${front} no longer keeps this forum`);
118
+ process.exit(0);
105
119
  } else if (cmd === 'status') {
106
120
  const agent = new ForumAgent({ home, log: () => {} });
107
121
  const up = await agent.connect({ act: false });
108
122
  console.log(JSON.stringify(up ? agent.status() : { mode: 'unconfigured' }, null, 2));
109
123
  process.exit(0);
110
124
  } else {
111
- console.log('usage: fedipod-bb <credential|init|start|status|attach> --home DIR [--email E --pod URL | --handle H --name N --category slug:Name … --reply-policy open|review | --front URL]');
125
+ console.log('usage: fedipod-bb <credential|init|start|status|attach|keep|unkeep> --home DIR [--email E --pod URL | --handle H --name N --category slug:Name … --reply-policy open|review | --front URL]');
112
126
  process.exit(2);
113
127
  }
package/package.json CHANGED
@@ -1,10 +1,16 @@
1
1
  {
2
2
  "name": "fedipod-bb",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "FediPod-BB: a forum website whose record lives on a Solid pod.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "src/index.mjs",
8
+ "exports": {
9
+ ".": "./src/index.mjs",
10
+ "./gateway": "./src/gateway.mjs",
11
+ "./src/*": "./src/*",
12
+ "./package.json": "./package.json"
13
+ },
8
14
  "bin": {
9
15
  "fedipod-bb": "bin/fedipod-bb.mjs"
10
16
  },
@@ -20,7 +26,7 @@
20
26
  "test": "node --test test/*.test.mjs"
21
27
  },
22
28
  "dependencies": {
23
- "fedipod": "^1.42.0",
29
+ "fedipod": "^1.43.0",
24
30
  "fediverse-session": "^0.2.1"
25
31
  }
26
32
  }
@@ -30,6 +30,8 @@ import * as moderation from './moderation.mjs';
30
30
  import * as access from './access.mjs';
31
31
  import { provisionForum, provisionCategory } from './provision.mjs';
32
32
  import * as settings from './settings.mjs';
33
+ import { keepForum, restateForumRules } from './keep.mjs';
34
+ import * as place from './place.mjs';
33
35
 
34
36
  import { ForumIntake } from './forum-intake.mjs';
35
37
  export { ForumIntake };
@@ -44,13 +46,24 @@ const VIEWER_REFRESH_MS = 5 * 60_000;
44
46
 
45
47
  export class ForumAgent {
46
48
  // `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 }) {
49
+ // A host with no directory of its own — fedipod.net's door or its keeper —
50
+ // hands in the credential, the lease and its holder id instead of files,
51
+ // names the keeper the pod's rules must carry, reads keys and never mints
52
+ // them, and defers every send to the run that follows.
53
+ constructor({ home = null, log = () => {}, remote = null, storageFor = defaultStorageFor, push = true, pollSeconds = null,
54
+ credential = null, lease = null, holderId = null, keepers = [], mintKeys = true, defer = false }) {
48
55
  this.home = home;
49
56
  this.log = log;
50
57
  this.remote = remote;
51
58
  this.storageFor = storageFor;
52
59
  this.push = push;
53
60
  this.pollSeconds = pollSeconds;
61
+ this.credential = credential;
62
+ this.givenLease = lease;
63
+ this.holderId = holderId;
64
+ this.keepers = keepers;
65
+ this.mintKeys = mintKeys;
66
+ this.defer = defer;
54
67
  this.categories = [];
55
68
  this.viewer = true;
56
69
  }
@@ -64,6 +77,8 @@ export class ForumAgent {
64
77
  // a moderator's ask — until it expired. Which is what a restart to pick up a
65
78
  // fix looked like from the outside: the fix changing nothing.
66
79
  hostId() {
80
+ if (this.holderId) return this.holderId;
81
+ if (!this.home) return null;
67
82
  const at = path.join(this.home, 'host-id');
68
83
  try { return fs.readFileSync(at, 'utf8').trim() || null; } catch { /* not yet */ }
69
84
  try {
@@ -78,10 +93,20 @@ export class ForumAgent {
78
93
  }
79
94
 
80
95
  readCredential() {
96
+ if (this.credential) return this.credential;
97
+ if (!this.home) return null;
81
98
  try { return JSON.parse(fs.readFileSync(path.join(this.home, 'credential.json'), 'utf8')); }
82
99
  catch { return null; }
83
100
  }
84
101
 
102
+ // The signing key of one actor, from its pod state. A host that may not
103
+ // mint — the door, which must never invent an identity's key — refuses a
104
+ // missing one instead.
105
+ async keysFor(store, actorId) {
106
+ if (!this.mintKeys && !store.read('keys.json', null)) throw new Error(`no signing key on the pod for ${actorId}`);
107
+ return resolveKeys(store, { localDir: null, actorId, log: this.log });
108
+ }
109
+
85
110
  status() {
86
111
  return {
87
112
  mode: !this.store ? 'unconfigured' : this.viewer ? 'viewer' : 'active',
@@ -142,10 +167,17 @@ export class ForumAgent {
142
167
  }
143
168
 
144
169
  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();
170
+ if (!this.remote) {
171
+ const { RemotePod } = await import('fedipod/remote');
172
+ this.remote = new RemotePod(cred, { log: this.log, home: this.home });
173
+ await this.remote.warmup();
174
+ }
175
+ // Every rule this host writes names the keeper beside the owner, and the
176
+ // owner is the forum's, whoever holds the pen.
177
+ if (this.keepers.length) {
178
+ this.remote.keepers = [...this.keepers];
179
+ this.remote.aclOwner = cred.webId || this.remote.aclOwner || null;
180
+ }
149
181
  }
150
182
 
151
183
  async connect({ act = true } = {}) {
@@ -162,7 +194,7 @@ export class ForumAgent {
162
194
  if (!this.config) { this.log('credential present but the forum has no config — run init'); return false; }
163
195
  const front = this.config.gateway?.front || null;
164
196
  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,
197
+ this.lease = this.givenLease || new Lease({ url: this.site.state + 'lease.json', fetchImpl: (u, i) => this.remote.fetch(u, i), log: this.log,
166
198
  id: this.hostId() });
167
199
  this.viewer = act ? !(await this.lease.acquire()) : true;
168
200
  this.categories = [];
@@ -225,11 +257,11 @@ export class ForumAgent {
225
257
  } } : {}),
226
258
  };
227
259
  store.setConfig(config);
228
- const keys = await resolveKeys(store, { localDir: null, actorId: urls.actor, log: this.log });
260
+ const keys = await this.keysFor(store, urls.actor);
229
261
  const cat = { slug, urls, store, config, remote: this.remote };
230
262
  cat.deliverer = new Deliverer({
231
263
  store, rsaPrivate: keys.rsaPrivate, keyId: urls.actor + '#main-key', actorId: urls.actor,
232
- edPrivate: keys.edPrivate, proofKeyId: assertionKeyId(urls), log: this.log, passive: true,
264
+ edPrivate: keys.edPrivate, proofKeyId: assertionKeyId(urls), log: this.log, passive: true, defer: this.defer,
233
265
  onGone: () => cat.publisher.publishCollections({ followers: true }),
234
266
  });
235
267
  cat.publisher = new Publisher({
@@ -254,7 +286,7 @@ export class ForumAgent {
254
286
  if (doc || !(this.config.membersOnly || []).includes(slug)) return doc;
255
287
  return this.podFetchAP(url);
256
288
  };
257
- cat.intake.onCarried = (ev) => this.onCarried(cat, ev);
289
+ cat.intake.onCarry = (ev) => this.onCarry(cat, ev);
258
290
  cat.intake.onReport = (activity, actor, opts) => cat.intake.queueModeration(activity, actor, opts);
259
291
  // What happens to a post from somebody who has not joined: dropped, or
260
292
  // held where a moderator will see it (the category's `replyPolicy`).
@@ -298,11 +330,11 @@ export class ForumAgent {
298
330
  mode: gw.mode || 'trust', hmacSecret: gw.secrets?.[this.config.handle] || null,
299
331
  } } : {}),
300
332
  };
301
- const keys = await resolveKeys(store, { localDir: null, actorId: site.actor, log: this.log });
333
+ const keys = await this.keysFor(store, site.actor);
302
334
  const agent = { urls: site, store, config, remote: this.remote, log: this.log, configured: () => true };
303
335
  agent.deliverer = new Deliverer({
304
336
  store, rsaPrivate: keys.rsaPrivate, keyId: site.actor + '#main-key', actorId: site.actor,
305
- edPrivate: keys.edPrivate, proofKeyId: assertionKeyId(site), log: this.log, passive: true,
337
+ edPrivate: keys.edPrivate, proofKeyId: assertionKeyId(site), log: this.log, passive: true, defer: this.defer,
306
338
  });
307
339
  agent.publisher = new Publisher({
308
340
  config, remote: this.remote, store, deliverer: agent.deliverer,
@@ -349,40 +381,27 @@ export class ForumAgent {
349
381
  return ids.some(id => id === s.actor || id === s.followers);
350
382
  }
351
383
 
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
+ // Placing a post in its topic, and what an edit or a deletion does to the
385
+ // place: place.mjs.
386
+ onCarry(cat, ev) { return place.placePost(this, cat, ev); }
387
+ onCarriedEdit(cat, ev) { return place.editPlaced(this, cat, ev); }
388
+ onCarriedGone(cat, ev) { return place.dropPlaced(this, cat, ev); }
389
+ countReplies(cat, tid, postId) { return place.countReplies(this, cat, tid, postId); }
390
+ noteLatest(cat, note) { return place.noteLatest(this, cat, note); }
391
+ dropLatest(cat, noteId) { return place.dropLatest(this, cat, noteId); }
392
+
393
+ // One delivery, handed in by whoever took it at the forum's door, done the
394
+ // way the drain does one item: handled, every store committed, and only
395
+ // then reported done — so the caller may remove it from the inbox.
396
+ async placeOne(name, body, receipt = null) {
397
+ const done = await this.intake.takeHeld([{ name, body, receipt }]);
398
+ return done.includes(name);
384
399
  }
385
400
 
401
+ // Letting a Gateway keep this forum, and taking that back: keep.mjs.
402
+ keep(o) { return keepForum(this, o); }
403
+ restateRules() { return restateForumRules(this); }
404
+
386
405
  // Take addresses at a Gateway: one row for the forum and one per category,
387
406
  // each a handle at the front, all writing their mail into the forum's one
388
407
  // inbox. The pod session proves the pod; no password leaves this machine.
@@ -464,45 +483,6 @@ export class ForumAgent {
464
483
  await cat.store.flush().catch(() => {});
465
484
  }
466
485
 
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
486
  // Queued asks from listed moderators that can be verified at their own
507
487
  // origin are applied; everything else waits for a person.
508
488
  async applyVerifiedAsks() {
@@ -752,28 +732,6 @@ export class ForumAgent {
752
732
  return true;
753
733
  }
754
734
 
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
735
  // Everything the forum already holds, read back out of the topics, for an
778
736
  // index that did not exist when those posts arrived.
779
737
  // Copies kept before the forum recorded which topic they were in: the
@@ -806,11 +764,6 @@ export class ForumAgent {
806
764
  return rows.length;
807
765
  }
808
766
 
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
767
  async applyModeration(slug, entryId) {
815
768
  const cat = this.categories.find(c => c.slug === slug);
816
769
  if (!cat) throw new Error(`no such category: ${slug}`);
@@ -858,8 +811,20 @@ export class ForumAgent {
858
811
  .catch(e => this.log(`the reader list for ${cat.slug}: ${e.message}`));
859
812
  await this.dropUnreadableFollowers(cat)
860
813
  .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}`));
814
+ // Two passes over what older versions wrote, made once and recorded
815
+ // (upkeep.json): each reads every topic or every copy, which is no way
816
+ // to begin every start.
817
+ const upkeep = cat.store.read('upkeep.json', {});
818
+ if (!upkeep.oldTopicsCarried) {
819
+ await this.carryOldTopics(cat);
820
+ cat.store.write('upkeep.json', { ...cat.store.read('upkeep.json', {}), oldTopicsCarried: new Date().toISOString() });
821
+ }
822
+ if (!upkeep.copiesStamped) {
823
+ try {
824
+ await this.stampOldCopies(cat);
825
+ cat.store.write('upkeep.json', { ...cat.store.read('upkeep.json', {}), copiesStamped: new Date().toISOString() });
826
+ } catch (e) { this.log(`stamping ${cat.slug}: ${e.message}`); }
827
+ }
863
828
  const seen = cat.store.read('published.json', {});
864
829
  if (force || !seen.actorDigest) {
865
830
  await cat.publisher.publishProfile({ force });
@@ -78,6 +78,20 @@ export class ForumIntake extends Intake {
78
78
  return ok;
79
79
  }
80
80
 
81
+ // Deliveries handed in rather than listed — one placed at the door, a batch
82
+ // the door held — are done only when every category's state is on the pod
83
+ // too, the same rule the drain keeps.
84
+ async takeHeld(entries) {
85
+ for (const cat of this.forum.categories) cat.store.hold?.();
86
+ let done;
87
+ try { done = await super.takeHeld(entries); }
88
+ finally { for (const cat of this.forum.categories) cat.store.release?.(); }
89
+ if (!done.length) return done;
90
+ let ok = true;
91
+ for (const cat of this.forum.categories) ok = (await cat.store.commit()) && ok;
92
+ return ok ? done : [];
93
+ }
94
+
81
95
  // Categories carry (FEP-1b12); the forum's own inbox forwards nothing.
82
96
  async _maybeForward() {}
83
97
 
@@ -97,6 +111,16 @@ export class ForumIntake extends Intake {
97
111
  } catch { return null; }
98
112
  }
99
113
 
114
+ // A receipt handed in with a delivery, the same way: any of the forum's
115
+ // secrets may have signed it.
116
+ async _checkReceipt(receipt) {
117
+ if (!receipt || typeof receipt !== 'object') return null;
118
+ const secrets = [this.config.gateway?.hmacSecret, ...this.forum.categories.map(c => c.config.gateway?.hmacSecret)].filter(Boolean);
119
+ if (!secrets.length) return null;
120
+ const { verifyReceipt } = await import('fedipod/gateway/httpsig.mjs');
121
+ return secrets.some(s => verifyReceipt(receipt, s)) ? receipt : null;
122
+ }
123
+
100
124
  gatewaySecret() {
101
125
  return this.config.gateway?.hmacSecret || this.forum.categories.find(c => c.config.gateway?.hmacSecret)?.config.gateway.hmacSecret || null;
102
126
  }
@@ -0,0 +1,209 @@
1
+ // gateway.mjs — the forum as fedipod.net runs it, with nothing running
2
+ // anywhere else. Two entries: placeAtDoor(), called by the door with the
3
+ // delivery it just wrote into the forum's inbox, places it in the same
4
+ // request; keepOnce(), the forum's run, carries what the door placed, drains
5
+ // what it left, applies moderators' asks and writes the heartbeat. Both work
6
+ // as the gateway's keeper identity, from the copies the gateway holds of the
7
+ // forum's and each category's state, under the forum's one copy lease.
8
+
9
+ import { PodTransport } from 'fedipod/pod/transport.mjs';
10
+ import { podBaseOfWebId } from 'fedipod/pod/urls.mjs';
11
+ import * as podInbox from 'fedipod/pod/inbox.mjs';
12
+ import { HttpStorage } from 'fedipod/core/storage.mjs';
13
+ import { Lease } from 'fedipod/core/lease.mjs';
14
+ import { CopyStorage, copyMeta, copyLease, lockCopy, GATEWAY_HOLDER } from 'fedipod/gateway/copy.mjs';
15
+ import { ensureCopy } from 'fedipod/gateway/state-api.mjs';
16
+ import { keeperSession } from 'fedipod/gateway/keeper-session.mjs';
17
+ import { ForumAgent } from './forum-agent.mjs';
18
+ import * as publish from './publish.mjs';
19
+
20
+ // How soon a run comes back for an ask that could not be applied yet.
21
+ const ASK_RETRY_MS = 60_000;
22
+
23
+ /** A forum's own row, or one of its categories': both write into one inbox. */
24
+ export const isForumRow = (rec) => !!rec?.inboxUrl && (rec.kind === 'group' || rec.kind === 'application');
25
+
26
+ /** The forum a row belongs to: its handle and row, and where it is on the pod. */
27
+ export async function forumFor(ctx, handle, rec) {
28
+ const forumHandle = rec.kind === 'application' ? handle : rec.forum;
29
+ if (!forumHandle) return { skipped: 'the category names no forum; run `fedipod-bb keep` again' };
30
+ const forumRec = forumHandle === handle ? rec : await ctx.lookup(forumHandle);
31
+ if (!forumRec || !isForumRow(forumRec) || !forumRec.keeper) return { skipped: `@${forumHandle} is not a forum this gateway keeps` };
32
+ if (!forumRec.webId || !forumRec.podHome) return { skipped: 'the forum row names no owner or pod' };
33
+ const home = forumRec.podHome.replace(/\/?$/u, '/');
34
+ const pod = podBaseOfWebId(forumRec.webId);
35
+ if (!home.startsWith(pod)) return { skipped: "the forum's folder is not on its owner's pod" };
36
+ return { forumHandle, forumRec, home, pod, root: home.slice(pod.length) };
37
+ }
38
+
39
+ // The gateway's pod fetch, as the keeper: a session handed in, the keeper's
40
+ // credential, or the front's own keeperFetch.
41
+ async function keeperFetchFor(ctx, session) {
42
+ if (session) return (u, i) => session.fetch(u, i);
43
+ if (ctx.keeperCredential) { const s = keeperSession(ctx.keeperCredential); return (u, i) => s.fetch(u, i); }
44
+ return ctx.keeperFetch ? ctx.keeperFetch() : null;
45
+ }
46
+
47
+ function transportFor(ctx, f, podFetch, log) {
48
+ const remote = new PodTransport({ fetch: podFetch }, { webId: ctx.keeperWebId, log, role: 'keeper', runtime: 'node', cooldownMode: 'refuse' });
49
+ // Any rule it writes names the forum's owner, and the keeper beside them.
50
+ remote.aclOwner = f.forumRec.webId;
51
+ remote.keepers = [ctx.keeperWebId];
52
+ remote.aclIfChanged = true;
53
+ return remote;
54
+ }
55
+
56
+ // Which copy holds a state container: the forum's own, or a category's. The
57
+ // inbox archive and anything else stays the pod's.
58
+ function copyHandleOf(base, f) {
59
+ if (base === f.home + 'ap-state/') return f.forumHandle;
60
+ const m = base.startsWith(f.home + 'c/') ? /^c\/([^/]+)\/ap-state\/$/u.exec(base.slice(f.home.length)) : null;
61
+ return m ? m[1] : null;
62
+ }
63
+
64
+ /** State storage for the forum: the copy where one exists, fenced by the forum's lease; the pod otherwise. */
65
+ export function storageFactory(ctx, f, podFetch, copies) {
66
+ return (base) => {
67
+ const pod = new HttpStorage(base, podFetch);
68
+ const h = copyHandleOf(base, f);
69
+ if (!h || !copies.has(h)) return pod;
70
+ return new CopyStorage(ctx.copyKv, h, { holder: GATEWAY_HOLDER, pod, podNames: ['keys.json'], fence: f.forumHandle });
71
+ };
72
+ }
73
+
74
+ async function readConfig(ctx, f, podFetch) {
75
+ const storage = await copyMeta(ctx.copyKv, f.forumHandle)
76
+ ? new CopyStorage(ctx.copyKv, f.forumHandle, { holder: GATEWAY_HOLDER })
77
+ : new HttpStorage(f.home + 'ap-state/', podFetch);
78
+ const r = await storage.read('config.json', { accept: 'application/json' }).catch(() => null);
79
+ if (!r?.ok || !r.body) return null;
80
+ try { return JSON.parse(r.body); } catch { return null; }
81
+ }
82
+
83
+ /**
84
+ * The copies the forum works from, the forum's and each category's, made
85
+ * from the pod where missing. A copy cannot be made while another host
86
+ * holds the pod's lease, which is how a forum still run elsewhere is left
87
+ * alone until that host stops.
88
+ */
89
+ export async function ensureForumCopies(ctx, f, config, log) {
90
+ const rows = [[f.forumHandle, f.forumRec]];
91
+ for (const c of config?.categories || []) {
92
+ const r = await ctx.lookup(c.slug);
93
+ if (r && isForumRow(r)) rows.push([c.slug, r]);
94
+ }
95
+ const have = new Set();
96
+ for (const [h, r] of rows) {
97
+ if (await copyMeta(ctx.copyKv, h)) { have.add(h); continue; }
98
+ const made = await ensureCopy(ctx, h, r, log);
99
+ if (made.ok) have.add(h); else log(`copy @${h}: ${made.why}`);
100
+ }
101
+ return have;
102
+ }
103
+
104
+ /** Which copies exist now, without making any. */
105
+ async function copiesOf(ctx, f, config) {
106
+ const have = new Set();
107
+ if (await copyMeta(ctx.copyKv, f.forumHandle)) have.add(f.forumHandle);
108
+ for (const c of config?.categories || []) if (await copyMeta(ctx.copyKv, c.slug)) have.add(c.slug);
109
+ return have;
110
+ }
111
+
112
+ function agentFor(ctx, f, { remote, podFetch, copies, log, mode }) {
113
+ const lease = copies.has(f.forumHandle)
114
+ ? copyLease(ctx.copyKv, f.forumHandle, { id: GATEWAY_HOLDER, log })
115
+ : new Lease({ url: f.home + 'ap-state/lease.json', fetchImpl: podFetch, log, id: `keeper:${ctx.keeperWebId}` });
116
+ return new ForumAgent({
117
+ log, remote, storageFor: storageFactory(ctx, f, podFetch, copies), push: false,
118
+ credential: { remotePod: f.pod, root: f.root, webId: f.forumRec.webId },
119
+ lease, holderId: GATEWAY_HOLDER, keepers: [ctx.keeperWebId],
120
+ // The run may mint a new forum's first keys; the door never mints.
121
+ mintKeys: mode === 'run', defer: mode === 'door',
122
+ });
123
+ }
124
+
125
+ // When the run should come back on its own: a delivery's next try, or an ask
126
+ // that could not be applied yet.
127
+ function nextRun(forum, asksPending) {
128
+ const times = [];
129
+ for (const s of [forum.store, ...forum.categories.map((c) => c.store)]) {
130
+ for (const item of s.getQueue?.() || []) if (item.nextAt) times.push(item.nextAt);
131
+ }
132
+ if (asksPending) times.push(Date.now() + ASK_RETRY_MS);
133
+ return times.length ? Math.min(...times) : null;
134
+ }
135
+
136
+ /**
137
+ * One run of the forum: what the door left in the inbox is drained, what it
138
+ * placed is carried, moderators' asks are applied, the heartbeat is written.
139
+ * Returns what the keeper's bookkeeping expects: { drained, waiting, nextAt }
140
+ * or { skipped, retry }.
141
+ */
142
+ export async function keepOnce(ctx, handle, rec, { log = console.log, session = null, remote: given = null, prepare = null } = {}) {
143
+ const f = await forumFor(ctx, handle, rec);
144
+ if (f.skipped) return { skipped: f.skipped };
145
+ const say = (m) => log(`forum @${f.forumHandle}: ${m}`);
146
+ const podFetch = given ? (u, i) => given.fetch(u, i) : await keeperFetchFor(ctx, session);
147
+ if (!podFetch) return { skipped: 'this gateway cannot reach pods for accounts' };
148
+ const remote = given || transportFor(ctx, f, podFetch, say);
149
+ const config = await readConfig(ctx, f, podFetch);
150
+ if (!config) return { skipped: 'no forum on the pod' };
151
+ const copies = await ensureForumCopies(ctx, f, config, say);
152
+ const unlock = await lockCopy(ctx.copyKv, f.forumHandle);
153
+ if (!unlock) return { skipped: 'the gateway is busy with this forum', retry: 'soon' };
154
+ try {
155
+ const forum = agentFor(ctx, f, { remote, podFetch, copies, log: say, mode: 'run' });
156
+ if (!await forum.connect({ act: false })) return { skipped: 'no forum on the pod' };
157
+ prepare?.(forum); // a test's origins, in place of the network
158
+ if (!await forum.lease.acquire()) return { skipped: 'another host has the forum', retry: 'soon' };
159
+ try {
160
+ await forum.publishAll();
161
+ await forum.intake.drain();
162
+ await forum.applyVerifiedAsks().catch((e) => say(`asks: ${e.message}`));
163
+ await forum.publishModQueue().catch((e) => say(`queue: ${e.message}`));
164
+ for (const cat of forum.categories) await cat.deliverer.drainQueue();
165
+ await forum.siteAgent.deliverer.drainQueue();
166
+ await publish.publishHeartbeat(forum.siteAgent).catch((e) => say(`heartbeat: ${e.message}`));
167
+ await Promise.allSettled([forum.store.flush(), ...forum.categories.map((c) => c.store.flush())]);
168
+ const waiting = [forum.store, ...forum.categories.map((c) => c.store)].reduce((n, s) => n + (s.getQueue?.() || []).length, 0);
169
+ const asks = [forum.store, ...forum.categories.map((c) => c.store)].reduce((n, s) => n + s.read('modqueue.json', []).length, 0);
170
+ say(`done: inbox drained, ${waiting} delivery(ies) waiting, ${asks} ask(s) pending`);
171
+ return { drained: true, waiting, nextAt: nextRun(forum, asks) };
172
+ } finally { await forum.lease.release().catch(() => {}); }
173
+ } finally { await unlock(); }
174
+ }
175
+
176
+ /**
177
+ * The delivery the door just wrote into the forum's inbox, placed now: the
178
+ * forum handles it with every send deferred, and when it is done the item
179
+ * and its receipt leave the inbox. Returns { placed, forum, why }. Whatever
180
+ * is not placed here stays in the inbox for the run the caller starts.
181
+ */
182
+ export async function placeAtDoor(ctx, handle, rec, { name, raw, receipt = null, log = console.log, waitMs = 3000, remote: given = null, prepare = null } = {}) {
183
+ const f = await forumFor(ctx, handle, rec);
184
+ if (f.skipped) return { placed: false, why: f.skipped };
185
+ const out = { placed: false, forum: f.forumHandle };
186
+ if (!await copyMeta(ctx.copyKv, f.forumHandle)) return { ...out, why: 'the forum has no copy yet' };
187
+ const podFetch = given ? (u, i) => given.fetch(u, i) : await keeperFetchFor(ctx, null);
188
+ if (!podFetch) return { ...out, why: 'this gateway cannot reach pods for accounts' };
189
+ const say = (m) => log(`door @${f.forumHandle}: ${m}`);
190
+ const remote = given || transportFor(ctx, f, podFetch, say);
191
+ const config = await readConfig(ctx, f, podFetch);
192
+ if (!config) return { ...out, why: 'no forum on the pod' };
193
+ const copies = await copiesOf(ctx, f, config);
194
+ const unlock = await lockCopy(ctx.copyKv, f.forumHandle, { waitMs });
195
+ if (!unlock) return { ...out, why: 'the forum is busy' };
196
+ try {
197
+ const forum = agentFor(ctx, f, { remote, podFetch, copies, log: say, mode: 'door' });
198
+ if (!await forum.connect({ act: false })) return { ...out, why: 'no forum on the pod' };
199
+ prepare?.(forum); // a test's origins, in place of the network
200
+ if (!await forum.lease.acquire()) return { ...out, why: 'another host has the forum' };
201
+ try {
202
+ if (!await forum.placeOne(name, raw, receipt)) return { ...out, why: 'not done here; left for the run' };
203
+ const item = f.home + 'ap/inbox/' + name;
204
+ await podInbox.dropHandledItem(remote, item).catch(() => false);
205
+ await podInbox.dropReceiptBeside(remote, item);
206
+ return { ...out, placed: true };
207
+ } finally { await forum.lease.release().catch(() => {}); }
208
+ } finally { await unlock(); }
209
+ }
package/src/keep.mjs ADDED
@@ -0,0 +1,50 @@
1
+ // keep.mjs — letting a Gateway keep this forum: it acts for the forum when
2
+ // nothing else does, from the pod's own rules, which name its keeper beside
3
+ // the owner from then on. Taking the name out again is the same in reverse.
4
+
5
+ import { restateRules } from 'fedipod/pod/containers.mjs';
6
+ import * as podInbox from 'fedipod/pod/inbox.mjs';
7
+ import * as publish from './publish.mjs';
8
+ import { provisionForum, provisionCategory } from './provision.mjs';
9
+
10
+ // Let a Gateway keep this forum: it acts for the forum when nothing else
11
+ // does, from the pod's own rules, which name it beside the owner from here
12
+ // on. `on: false` takes the name out again.
13
+ export async function keepForum(forum, { front, on = true }) {
14
+ const origin = String(front).replace(/\/+$/u, '');
15
+ const handles = [forum.config.handle, ...forum.categories.map(c => c.slug)];
16
+ let keeper = null;
17
+ for (const handle of handles) {
18
+ const res = await forum.remote.session.fetch(`${origin}/api/keeper`, {
19
+ method: 'POST', headers: { 'content-type': 'application/json' },
20
+ body: JSON.stringify({ handle, on, ...(handle === forum.config.handle ? {} : { forum: forum.config.handle }) }),
21
+ });
22
+ const d = await res.json().catch(() => ({}));
23
+ if (!res.ok) throw new Error(`${origin} would not ${on ? 'keep' : 'let go of'} @${handle}: ${d.error || res.status}`);
24
+ keeper = d.keeper || keeper;
25
+ }
26
+ forum.keepers = on && keeper ? [keeper] : [];
27
+ forum.remote.keepers = [...forum.keepers];
28
+ forum.remote.aclOwner = forum.readCredential()?.webId || forum.remote.aclOwner || null;
29
+ await forum.restateRules();
30
+ return { keeper: on ? keeper : null, handles, on };
31
+ }
32
+
33
+ // Every rule on the forum's tree, stated again with the names this host
34
+ // carries: the containers, the inboxes, the reader lists, and the documents
35
+ // that state a rule of their own.
36
+ export async function restateForumRules(forum) {
37
+ await provisionForum(forum.remote, forum.site, { moderatorWebIds: forum.config.moderatorWebIds || [] });
38
+ await restateRules(forum.remote, forum.site).catch(e => forum.log(`restating the forum's rules: ${e.message}`));
39
+ await podInbox.setPosture(forum.remote, forum.site, 'open');
40
+ for (const cat of forum.categories) {
41
+ await provisionCategory(forum.remote, cat.urls, { memberWebIds: forum.membersOf(cat) });
42
+ await restateRules(forum.remote, cat.urls).catch(e => forum.log(`restating ${cat.slug}'s rules: ${e.message}`));
43
+ await publish.publishMembers(cat, forum.readersOf(cat), { force: true }).catch(e => forum.log(`the reader list for ${cat.slug}: ${e.message}`));
44
+ await podInbox.setPosture(forum.remote, cat.urls, 'closed');
45
+ for (const base of [cat.urls.topicContainer, cat.urls.cache]) {
46
+ const children = await forum.remote.listContainer?.(base).catch(() => []) || [];
47
+ for (const child of children) if (!child.url.endsWith('/')) await forum.remote.restateAcl?.(child.url).catch(() => null);
48
+ }
49
+ }
50
+ }
package/src/place.mjs ADDED
@@ -0,0 +1,110 @@
1
+ // place.mjs — placing a post in its topic, keeping the copy readers see, and
2
+ // what an edit or a deletion does to that place. Called by a category's
3
+ // intake before it carries a post, and by the door that takes one as it
4
+ // lands.
5
+
6
+ import * as topics from './topics.mjs';
7
+ import * as publish from './publish.mjs';
8
+ import * as moderation from './moderation.mjs';
9
+
10
+ const idOf = (v) => (typeof v === 'string' ? v : v?.id);
11
+
12
+ // Before a category carries a post: place it in its topic, keep a copy for
13
+ // readers, and publish what changed. A locked topic takes no more: the post
14
+ // is not placed and, answered with false, not carried either.
15
+ export async function placePost(forum, cat, { noteId, sent }) {
16
+ let note = cat.intake.recentNotes.get(noteId) || null;
17
+ cat.intake.recentNotes.delete(noteId);
18
+ if (!note) note = await cat.intake.fetchAP(noteId);
19
+ if (!note) throw new Error(`${noteId} could not be read for its topic`);
20
+ const tid = await topics.assign({ store: cat.store, urls: cat.urls, fetchAP: (u) => cat.intake.fetchAP(u) }, note, sent);
21
+ if (moderation.isLocked(cat, tid) && !topics.list(cat.store).find(t => t.tid === tid && t.op === noteId)) {
22
+ topics.remove(cat.store, tid, noteId);
23
+ forum.log(`${cat.slug}: ${noteId} not placed — topic ${tid} is locked`);
24
+ return false;
25
+ }
26
+ await publish.cachePost(cat, note, { topic: cat.urls.topic(tid), replies: 0 });
27
+ noteLatest(forum, cat, note);
28
+ const answered = idOf(note.inReplyTo);
29
+ if (answered) await countReplies(forum, cat, tid, answered).catch(e => forum.log(`replies of ${answered}: ${e.message}`));
30
+ // The author's card, from the actor the intake already holds; fetched
31
+ // once when it does not, so the website can name them.
32
+ const author = idOf([].concat(note.attributedTo || [])[0]);
33
+ if (author) {
34
+ const held = cat.store.getActors?.()[author];
35
+ const doc = held ? { id: author, ...held } : await cat.intake.fetchAP(author).catch(() => null);
36
+ if (doc) await publish.cacheAuthor(cat, doc).catch(e => forum.log(`author card for ${author}: ${e.message}`));
37
+ }
38
+ await publish.publishTopic(cat, tid);
39
+ await publish.publishTopicIndex(cat);
40
+ await publish.publishLatest(forum.siteAgent);
41
+ forum.log(`${cat.slug}: ${noteId} in topic ${tid}`);
42
+ return true;
43
+ }
44
+
45
+ // An author edited a post of theirs that we hold: the copy the website
46
+ // reads is rewritten from the note as verified at its origin, and a changed
47
+ // title is the topic's title when that post opened it.
48
+ export async function editPlaced(forum, cat, { noteId, note }) {
49
+ // The copy is rewritten from the note as verified at its origin; the
50
+ // counts the forum keeps on it are put back from what the forum knows.
51
+ const v = cat.store.read('votes.json', {})[noteId];
52
+ const held = topics.topicOf(cat.store, noteId);
53
+ const replies = held ? (topics.get(cat.store, held)?.posts || []).filter(p => p.inReplyTo === noteId).length : null;
54
+ await publish.cachePost(cat, note, { topic: held ? cat.urls.topic(held) : null, replies,
55
+ likes: (Array.isArray(v) ? v : v?.up || []).length, dislikes: (Array.isArray(v) ? [] : v?.down || []).length });
56
+ await publish.publishLatest(forum.siteAgent);
57
+ const tid = topics.topicOf(cat.store, noteId);
58
+ if (!tid) return;
59
+ await publish.publishTopic(cat, tid, { force: true });
60
+ }
61
+
62
+ // An author deleted one: it leaves the topic, its copy becomes a tombstone
63
+ // (FEP-4f05), and a topic with nothing left in it goes too. The carry has
64
+ // already been taken back by the group itself.
65
+ export async function dropPlaced(forum, cat, { noteId }) {
66
+ const tid = topics.topicOf(cat.store, noteId);
67
+ // Whatever it answered has one fewer answer now.
68
+ const answered = tid ? idOf(topics.get(cat.store, tid)?.posts?.find(p => p.id === noteId)?.inReplyTo) : null;
69
+ dropLatest(forum, cat, noteId);
70
+ await publish.tombstoneCached(cat, noteId);
71
+ await publish.publishLatest(forum.siteAgent);
72
+ if (!tid) return;
73
+ topics.remove(cat.store, tid, noteId);
74
+ const left = topics.get(cat.store, tid);
75
+ if (!left?.posts?.length) {
76
+ await moderation.deleteTopic(cat, tid).catch(e => forum.log(`empty topic ${tid}: ${e.message}`));
77
+ return;
78
+ }
79
+ await publish.publishTopic(cat, tid, { force: true });
80
+ await publish.publishTopicIndex(cat, { force: true });
81
+ if (answered) await countReplies(forum, cat, tid, answered).catch(e => forum.log(`replies of ${answered}: ${e.message}`));
82
+ }
83
+
84
+ // Answers to one post, counted in the topic that holds it, and written
85
+ // into the copy the website reads.
86
+ export async function countReplies(forum, cat, tid, postId) {
87
+ const doc = topics.get(cat.store, tid);
88
+ if (!doc) return;
89
+ const n = (doc.posts || []).filter(p => p.inReplyTo === postId).length;
90
+ const copy = await forum.remote.getJson(cat.urls.cached(postId)).catch(() => null);
91
+ if (!copy || copy.type === 'Tombstone') return;
92
+ if (Number(copy.replies?.totalItems) === n) return;
93
+ await publish.cachePost(cat, copy, { replies: n });
94
+ }
95
+
96
+ // The forum's own index of its newest posts, across every category.
97
+ export function noteLatest(forum, cat, note) {
98
+ const copy = cat.urls.cached(note.id);
99
+ const at = note.published || new Date().toISOString();
100
+ const rows = forum.store.read('latest.json', []).filter(e => e.copy !== copy);
101
+ rows.push({ copy, at });
102
+ rows.sort((a, b) => String(b.at).localeCompare(String(a.at)));
103
+ forum.store.write('latest.json', rows.slice(0, publish.LATEST_MAX));
104
+ }
105
+
106
+ export function dropLatest(forum, cat, noteId) {
107
+ const copy = cat.urls.cached(noteId);
108
+ forum.store.write('latest.json', forum.store.read('latest.json', []).filter(e => e.copy !== copy));
109
+ }
110
+