fedipod-bb 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -21,6 +21,12 @@ node --test packages/fedipod-bb/test/*.test.mjs
21
21
 
22
22
  ## Hosting a forum
23
23
 
24
+ A forum can be made at a FediPod Gateway's sign-up page (fedipod.net's, for
25
+ one) with no machine of your own: choose "A forum", make a pod for it at a
26
+ provider, name it and its categories, and the Gateway writes it on the pod
27
+ and runs it from then on. The commands below are for running a forum from a
28
+ machine of your own instead.
29
+
24
30
  ```
25
31
  npm install -g fedipod-bb
26
32
  fedipod-bb credential --home DIR --email you@example.org --pod https://forum.example/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod-bb",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "FediPod-BB: a forum website whose record lives on a Solid pod.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -8,6 +8,7 @@
8
8
  "exports": {
9
9
  ".": "./src/index.mjs",
10
10
  "./gateway": "./src/gateway.mjs",
11
+ "./setup": "./src/setup.mjs",
11
12
  "./src/*": "./src/*",
12
13
  "./package.json": "./package.json"
13
14
  },
@@ -26,7 +27,7 @@
26
27
  "test": "node --test test/*.test.mjs"
27
28
  },
28
29
  "dependencies": {
29
- "fedipod": "^1.43.0",
30
+ "fedipod": "^1.44.0",
30
31
  "fediverse-session": "^0.2.1"
31
32
  }
32
33
  }
package/site/bb.js CHANGED
@@ -39,8 +39,14 @@ const place = placeOf({ origin: location.origin, pathname: location.pathname, se
39
39
  const { front, base } = place;
40
40
  const frontHost = new URL(front).host;
41
41
  // The reader asks anonymously first; where a category is members-only it
42
- // asks again with the pod session, if there is one.
43
- let read = reader();
42
+ // asks again with the pod session, if there is one. Right after this reader
43
+ // posts, the next read goes past the browser's own copy of each document (a
44
+ // front lets a browser keep one for a minute): a post the forum placed as it
45
+ // arrived is then on the page at once, not a minute later.
46
+ let freshRead = false;
47
+ const readerFetch = (url, init = {}) => fetch(url, freshRead ? { ...init, cache: 'reload' } : init);
48
+ const makeReader = (session = null) => reader({ session, fetch: readerFetch });
49
+ let read = makeReader();
44
50
  const login = new MastoLogin({ storage: localStorage, redirectUri: location.origin + location.pathname + location.search });
45
51
  // What this reader has already read. Theirs, in their own browser: a public
46
52
  // forum has nowhere to keep it and no business keeping it.
@@ -989,7 +995,7 @@ async function report(id) {
989
995
  if (why === null) return;
990
996
  try {
991
997
  const cat = replyCtx.cat;
992
- const inbox = await pod.podInboxOf(cat.id, { front, handle: cat.slug || null });
998
+ const inbox = await pod.podInboxOf(cat.id);
993
999
  const who = podAcct?.actor || login.account()?.url;
994
1000
  await pod.report({ actor: who, object: id, category: cat.id, inbox, why });
995
1001
  alert('Reported. A moderator will see it in the forum\'s queue.');
@@ -1007,7 +1013,7 @@ async function asksTo(activity, forCat = null) {
1007
1013
  // The same reason voteOn says it.
1008
1014
  let step = 'finding where the forum takes mail';
1009
1015
  try {
1010
- const inbox = await pod.podInboxOf(cat.id, { front, handle: cat.slug || null });
1016
+ const inbox = await pod.podInboxOf(cat.id);
1011
1017
  if (!inbox) throw new Error('the forum did not say where to send it');
1012
1018
  // moderate() names its own two requests, so nothing is added over the top.
1013
1019
  step = 'sending the request';
@@ -1049,7 +1055,7 @@ async function joinOrLeave(categoryId, on) {
1049
1055
  return;
1050
1056
  }
1051
1057
  try {
1052
- const inbox = await pod.podInboxOf(cat.id, { front, handle: cat.slug || null });
1058
+ const inbox = await pod.podInboxOf(cat.id);
1053
1059
  const ok = on
1054
1060
  ? await pod.join({ actor: podAcct.actor, category: cat.id, inbox })
1055
1061
  : await pod.leave({ actor: podAcct.actor, category: cat.id, inbox });
@@ -1129,7 +1135,7 @@ async function voteOn(postId, way, was) {
1129
1135
  if (!cat) return;
1130
1136
  let step = 'finding where the forum takes mail';
1131
1137
  try {
1132
- const inbox = await pod.podInboxOf(cat.id, { front, handle: cat.slug || null });
1138
+ const inbox = await pod.podInboxOf(cat.id);
1133
1139
  if (!inbox) throw new Error('the forum did not say where to send it');
1134
1140
  step = `sending the vote to ${new URL(inbox).host}`;
1135
1141
  await pod.vote({ actor: podAcct.actor, post: postId, category: cat.id, inbox, way, was });
@@ -1207,7 +1213,7 @@ async function askToJoin(categoryId) {
1207
1213
  const cat = cats().find(c => c.id === categoryId);
1208
1214
  if (!cat || !podAcct) return;
1209
1215
  try {
1210
- const inbox = await pod.podInboxOf(cat.id, { front, handle: cat.slug || null });
1216
+ const inbox = await pod.podInboxOf(cat.id);
1211
1217
  await pod.join({ actor: podAcct.actor, category: cat.id, inbox });
1212
1218
  say('Asked. A moderator sees it in the forum\'s queue.');
1213
1219
  alert('Your request is with the moderators.');
@@ -1326,7 +1332,7 @@ function settingsSaid(text, bad = false) {
1326
1332
 
1327
1333
  async function settingsAsk(...activities) {
1328
1334
  try {
1329
- const inbox = await pod.podInboxOf(cats()[0]?.id || forum.id, { front, handle: cats()[0]?.slug || forum.handle });
1335
+ const inbox = await pod.podInboxOf(cats()[0]?.id || forum.id);
1330
1336
  for (const activity of activities) {
1331
1337
  await pod.moderate({ actor: podAcct.actor, podHome: podAcct.podHome, inbox, activity });
1332
1338
  }
@@ -1404,7 +1410,7 @@ async function removePost(id) {
1404
1410
  try {
1405
1411
  const cat = replyCtx.cat;
1406
1412
  if (podAcct) {
1407
- const inbox = await pod.podInboxOf(cat.id, { front, handle: cat.slug || null });
1413
+ const inbox = await pod.podInboxOf(cat.id);
1408
1414
  await pod.remove({ actor: podAcct.actor, podHome: podAcct.podHome, id, category: cat.id,
1409
1415
  categoryBase: cat.base, isPrivate: !!cat.private, inbox });
1410
1416
  } else {
@@ -1534,7 +1540,7 @@ $('who-act').addEventListener('click', async () => {
1534
1540
  async function saveEdit({ body }) {
1535
1541
  const cat = replyCtx.cat;
1536
1542
  if (podAcct) {
1537
- const inbox = await pod.podInboxOf(cat.id, { front, handle: cat.slug || null });
1543
+ const inbox = await pod.podInboxOf(cat.id);
1538
1544
  return pod.edit({ actor: podAcct.actor, podHome: podAcct.podHome, id: editing.id, text: body, category: cat.id,
1539
1545
  categoryBase: cat.base, isPrivate: !!cat.private, inbox });
1540
1546
  }
@@ -1545,7 +1551,7 @@ async function saveEdit({ body }) {
1545
1551
  // category is named on its own pod, which is where a delivery is taken.
1546
1552
  async function postFromPod({ topic, body }) {
1547
1553
  const cat = replyCtx.cat;
1548
- const inbox = await pod.podInboxOf(cat.id, { front, handle: cat.slug || null });
1554
+ const inbox = await pod.podInboxOf(cat.id);
1549
1555
  if (!inbox) throw new Error('the forum did not say where to send it');
1550
1556
  // Posting in an open category joins it on the way past, which is what makes
1551
1557
  // the post carry. A private one is joined by asking and being admitted, so
@@ -1584,7 +1590,8 @@ $('reply-send').addEventListener('click', async () => {
1584
1590
  $('topic-title').value = '';
1585
1591
  $('reply-dlg').close();
1586
1592
  forgetIndex();
1587
- await route();
1593
+ freshRead = true;
1594
+ try { await route(); } finally { freshRead = false; }
1588
1595
  } catch (e) { $('reply-err').textContent = e.message; }
1589
1596
  $('reply-send').disabled = false;
1590
1597
  });
@@ -1600,7 +1607,7 @@ $('reply-send').addEventListener('click', async () => {
1600
1607
  if (kept && !sessionStorage.getItem('bb:pod')) {
1601
1608
  try {
1602
1609
  const s = await pod.session();
1603
- if (s) { podAcct = { ...JSON.parse(kept), webId: s.webId }; read = reader({ session: s }); }
1610
+ if (s) { podAcct = { ...JSON.parse(kept), webId: s.webId }; read = makeReader(s); }
1604
1611
  else localStorage.removeItem('bb:acct');
1605
1612
  } catch { localStorage.removeItem('bb:acct'); }
1606
1613
  }
@@ -1611,7 +1618,7 @@ $('reply-send').addEventListener('click', async () => {
1611
1618
  const s = params.get('code') ? await pod.complete(location.href) : await pod.session();
1612
1619
  if (s) {
1613
1620
  podAcct = { ...who, webId: s.webId };
1614
- read = reader({ session: s });
1621
+ read = makeReader(s);
1615
1622
  try { localStorage.setItem('bb:acct', JSON.stringify(who)); } catch { /* blocked storage */ }
1616
1623
  sessionStorage.removeItem('bb:pod');
1617
1624
  params.delete('code'); params.delete('state'); params.delete('iss');
package/site/pod.mjs CHANGED
@@ -84,18 +84,12 @@ async function placeAndAudience(s, { actor, podHome, category, categoryBase, isP
84
84
  return { where, to: [categoryBase + 'ap/followers'] };
85
85
  }
86
86
 
87
- // What the forum answers as, on its own pod: the page reads a category
88
- // through the Gateway, whose ids are the front's, but a post is delivered to
89
- // the pod itself.
90
- export async function podInboxOf(categoryActorId, { front = null, handle = null, fetch: f = globalThis.fetch.bind(globalThis) } = {}) {
91
- // Through a Gateway the category's actor names the Gateway's own door, which
92
- // takes signed mail between servers. A post made here is the reader's own,
93
- // so it goes to the pod inbox the Gateway names when asked.
94
- if (front && handle && categoryActorId.startsWith(front)) {
95
- const said = await f(`${front}/api/server?host=${encodeURIComponent(new URL(front).host)}&handle=${encodeURIComponent(handle)}`,
96
- { headers: { accept: 'application/json' } }).then(r => (r.ok ? r.json() : null)).catch(() => null);
97
- if (said?.inbox) return said.inbox;
98
- }
87
+ // Where a category takes what is sent to it: the inbox its actor names. Through
88
+ // a Gateway that is the Gateway's door, which writes the delivery into the
89
+ // forum's pod inbox for whatever runs the forum, and, when the Gateway keeps
90
+ // the forum itself, places the post before it answers. Read from the pod
91
+ // outright, it is the pod inbox.
92
+ export async function podInboxOf(categoryActorId, { fetch: f = globalThis.fetch.bind(globalThis) } = {}) {
99
93
  const doc = await f(categoryActorId, { headers: { accept: 'application/activity+json' } }).then(r => (r.ok ? r.json() : null)).catch(() => null);
100
94
  const inbox = doc?.endpoints?.sharedInbox || doc?.inbox;
101
95
  return typeof inbox === 'string' ? inbox : null;
@@ -31,6 +31,7 @@ import * as access from './access.mjs';
31
31
  import { provisionForum, provisionCategory } from './provision.mjs';
32
32
  import * as settings from './settings.mjs';
33
33
  import { keepForum, restateForumRules } from './keep.mjs';
34
+ import { writeForumConfig, attachRows } from './setup.mjs';
34
35
  import * as place from './place.mjs';
35
36
 
36
37
  import { ForumIntake } from './forum-intake.mjs';
@@ -131,39 +132,11 @@ export class ForumAgent {
131
132
  const cred = this.readCredential();
132
133
  if (!cred) throw new Error('no credential.json — make one first');
133
134
  await this.attachRemote(cred);
134
- const site = forumUrls(cred.remotePod, cred.root || ROOT);
135
- await provisionForum(this.remote, site);
136
- const store = new PodStore({ log: this.log });
137
- store.attach(this.storageFor(site.state, (u, i) => this.remote.fetch(u, i)));
138
- await store.load().catch(() => {});
139
- const existing = store.getConfig() || {};
140
- const cats = categories.map(c => (typeof c === 'string' ? { slug: c, name: c } : c));
141
- for (const c of cats) if (!isSlug(c.slug)) throw new Error(`not a category slug: ${c.slug}`);
142
- // A renamed forum has to be published again: the profile is written only
143
- // when its digest changed or something asks, and a name lives in the
144
- // actor document, not in the config alone.
145
- // A changed name — the forum's or any category's — lives in a published
146
- // actor, and the profile is only rewritten when its digest changed or
147
- // something asks. So it asks.
148
- const was = JSON.stringify((existing.categories || []).map(c => [c.slug, c.name]));
149
- // Who may read what is written into the pod's own access rules, which are
150
- // only written when something asks for a republish — so a change to them
151
- // asks for one.
152
- const access = JSON.stringify([existing.membersOnly || [], existing.memberWebIds || {}, existing.moderatorWebIds || []]);
153
- const accessChanged = !!existing.handle && access !== JSON.stringify([membersOnly, memberWebIds, moderatorWebIds]);
154
- const renamed = !!existing.handle
155
- && ((name || handle) !== existing.name || was !== JSON.stringify(cats.map(c => [c.slug, c.name])));
156
- store.setConfig({
157
- ...existing, kind: 'application', handle, name: name || existing.name || handle,
158
- ...(renamed || accessChanged ? { republish: true } : {}),
159
- ...(accessChanged ? { reprovision: true } : {}),
160
- remotePod: cred.remotePod, root: cred.root || ROOT,
161
- categories: cats, moderators, moderatorWebIds, membersOnly, memberWebIds,
162
- approveJoins, review, replyPolicy,
163
- });
164
- await store.flush();
165
- this.log(`forum ${handle} initialised with ${cats.length} categor${cats.length === 1 ? 'y' : 'ies'}`);
166
- return store.getConfig();
135
+ const { config } = await writeForumConfig(this.remote, this.storageFor, {
136
+ remotePod: cred.remotePod, root: cred.root || ROOT, handle, name, categories, moderators, moderatorWebIds,
137
+ membersOnly, memberWebIds, approveJoins, review, replyPolicy,
138
+ }, { log: this.log });
139
+ return config;
167
140
  }
168
141
 
169
142
  async attachRemote(cred) {
@@ -412,28 +385,14 @@ export class ForumAgent {
412
385
  const cred = this.readCredential();
413
386
  if (!cred || !this.store || !this.config) throw new Error('connect first');
414
387
  const plain = forumUrls(cred.remotePod, cred.root || ROOT);
415
- const post = attachOne || (async (body) => {
416
- const res = await this.remote.session.fetch(`${origin}/api/attach`, {
417
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
418
- });
419
- const d = await res.json().catch(() => ({}));
420
- if (res.status !== 201 || !d.hmacSecret) throw new Error(`attach ${body.handle} at ${origin}: HTTP ${res.status}${d.error ? ' ' + d.error : ''}`);
421
- return d;
422
- });
423
- const secrets = {};
424
- const rows = [
425
- { handle: this.config.handle, podHome: plain.home, actorUrl: plain.actor, kind: 'application' },
426
- ...(this.config.categories || []).map(c => ({ handle: c.slug, podHome: plain.category(c.slug).home,
427
- actorUrl: plain.category(c.slug).actor, kind: 'group' })),
428
- ];
429
- for (const row of rows) {
430
- const d = await post({ ...row, fronted: true, inboxUrl: plain.inbox });
431
- secrets[row.handle] = String(d.hmacSecret);
432
- this.log(`attached @${row.handle}@${new URL(origin).host}`);
433
- }
434
- this.store.setConfig({ ...this.store.getConfig(), gateway: { front: origin, mode: 'trust', secrets }, republish: true });
388
+ // A test hands in the attach itself; otherwise the pod session proves the owner.
389
+ const f = attachOne
390
+ ? async (_url, init) => { const d = await attachOne(JSON.parse(init.body)); return { status: 201, json: async () => d }; }
391
+ : (u, i) => this.remote.session.fetch(u, i);
392
+ const { gateway, handles } = await attachRows({ fetch: f, front: origin, config: this.config, plain }, { log: this.log });
393
+ this.store.setConfig({ ...this.store.getConfig(), gateway, republish: true });
435
394
  await this.store.flush();
436
- return { front: origin, handles: rows.map(r => r.handle) };
395
+ return { front: origin, handles };
437
396
  }
438
397
 
439
398
  // One category's handle at the Gateway, claimed when a moderator creates
package/src/gateway.mjs CHANGED
@@ -167,6 +167,12 @@ export async function keepOnce(ctx, handle, rec, { log = console.log, session =
167
167
  await Promise.allSettled([forum.store.flush(), ...forum.categories.map((c) => c.store.flush())]);
168
168
  const waiting = [forum.store, ...forum.categories.map((c) => c.store)].reduce((n, s) => n + (s.getQueue?.() || []).length, 0);
169
169
  const asks = [forum.store, ...forum.categories.map((c) => c.store)].reduce((n, s) => n + s.read('modqueue.json', []).length, 0);
170
+ // The run wrote on the pod (what the door could not place, the queue,
171
+ // the heartbeat): the edge's copies of the forum's documents go.
172
+ if (ctx.purge) {
173
+ const tags = [`u-${f.forumHandle}`, ...(config.categories || []).map((c) => `u-${c.slug}`)];
174
+ await ctx.purge(tags).catch((e) => say(`purge: ${e.message}`));
175
+ }
170
176
  say(`done: inbox drained, ${waiting} delivery(ies) waiting, ${asks} ask(s) pending`);
171
177
  return { drained: true, waiting, nextAt: nextRun(forum, asks) };
172
178
  } finally { await forum.lease.release().catch(() => {}); }
package/src/index.mjs CHANGED
@@ -6,4 +6,5 @@ export * as topics from './topics.mjs';
6
6
  export * as publish from './publish.mjs';
7
7
  export * as moderation from './moderation.mjs';
8
8
  export { provisionForum, provisionCategory } from './provision.mjs';
9
+ export * as setup from './setup.mjs';
9
10
  export { ForumAgent, ForumIntake } from './forum-agent.mjs';
package/src/keep.mjs CHANGED
@@ -6,23 +6,13 @@ import { restateRules } from 'fedipod/pod/containers.mjs';
6
6
  import * as podInbox from 'fedipod/pod/inbox.mjs';
7
7
  import * as publish from './publish.mjs';
8
8
  import { provisionForum, provisionCategory } from './provision.mjs';
9
+ import { keepRows } from './setup.mjs';
9
10
 
10
11
  // Let a Gateway keep this forum: it acts for the forum when nothing else
11
12
  // does, from the pod's own rules, which name it beside the owner from here
12
13
  // on. `on: false` takes the name out again.
13
14
  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
- }
15
+ const { keeper, handles } = await keepRows({ fetch: (u, i) => forum.remote.session.fetch(u, i), front, config: forum.config, on });
26
16
  forum.keepers = on && keeper ? [keeper] : [];
27
17
  forum.remote.keepers = [...forum.keepers];
28
18
  forum.remote.aclOwner = forum.readCredential()?.webId || forum.remote.aclOwner || null;
package/src/setup.mjs ADDED
@@ -0,0 +1,163 @@
1
+ // setup.mjs — bringing a forum into being on its pod, from wherever its owner
2
+ // is: a moderator's machine (`fedipod-bb init`, `attach`, `keep`) or the
3
+ // browser at a Gateway's sign-up page. Config, containers and rules, the rows
4
+ // at a Gateway and the keeper's name: nothing here runs the forum. Every
5
+ // function takes the pod transport and the fetch it is handed; none reaches
6
+ // for a file, a credential or the network on its own, so the same code runs
7
+ // in Node and in a page.
8
+
9
+ import { restateRules } from 'fedipod/pod/containers.mjs';
10
+ import { forumUrls, ROOT, isSlug } from './urls.mjs';
11
+
12
+ export { ROOT };
13
+ import { provisionForum, provisionCategory } from './provision.mjs';
14
+
15
+ /**
16
+ * The forum's config, from what it was and what is asked. A renamed forum or
17
+ * a changed reader list has to be published again — the actors and the rules
18
+ * are only rewritten when something asks — so the config says so.
19
+ */
20
+ export function forumConfig(existing = {}, { remotePod, root = ROOT, handle, name, categories = [], moderators = [],
21
+ moderatorWebIds = [], membersOnly = [], memberWebIds = {}, approveJoins = false, review = false, replyPolicy = 'open' }) {
22
+ if (!isSlug(handle)) throw new Error(`not a forum handle: ${handle}`);
23
+ const cats = categories.map(c => (typeof c === 'string' ? { slug: c, name: c } : c));
24
+ for (const c of cats) if (!isSlug(c.slug)) throw new Error(`not a category slug: ${c.slug}`);
25
+ const was = JSON.stringify((existing.categories || []).map(c => [c.slug, c.name]));
26
+ const access = JSON.stringify([existing.membersOnly || [], existing.memberWebIds || {}, existing.moderatorWebIds || []]);
27
+ const accessChanged = !!existing.handle && access !== JSON.stringify([membersOnly, memberWebIds, moderatorWebIds]);
28
+ const renamed = !!existing.handle
29
+ && ((name || handle) !== existing.name || was !== JSON.stringify(cats.map(c => [c.slug, c.name])));
30
+ return {
31
+ ...existing, kind: 'application', handle, name: name || existing.name || handle,
32
+ ...(renamed || accessChanged ? { republish: true } : {}),
33
+ ...(accessChanged ? { reprovision: true } : {}),
34
+ remotePod, root,
35
+ categories: cats, moderators, moderatorWebIds, membersOnly, memberWebIds,
36
+ approveJoins, review, replyPolicy,
37
+ };
38
+ }
39
+
40
+ // The config document, written the way the state store writes it (one JSON
41
+ // document per name in the state container), without the store itself: a
42
+ // page that makes a forum need not carry the store's whole apparatus.
43
+ const CONFIG = 'config.json';
44
+ const serialise = (obj) => JSON.stringify(obj, null, 2) + '\n';
45
+ async function writeConfig(storage, config) {
46
+ const w = await storage.write(CONFIG, serialise(config), 'application/json');
47
+ if (!w.ok) throw new Error(`the forum's config could not be written${w.why ? ` (${w.why})` : ''}`);
48
+ }
49
+
50
+ /**
51
+ * The first act on a pod: the forum's containers and its config, nothing
52
+ * else — the actors are published by the first run. `storageFor(base,
53
+ * fetch)` is the owner-only state storage the caller uses. Returns the
54
+ * storage, the config and the forum's plain urls, for whatever the caller
55
+ * does next.
56
+ */
57
+ export async function writeForumConfig(remote, storageFor, opts, { log = () => {} } = {}) {
58
+ const plain = forumUrls(opts.remotePod, opts.root || ROOT);
59
+ await provisionForum(remote, plain);
60
+ const storage = storageFor(plain.state, (u, i) => remote.fetch(u, i));
61
+ const had = await storage.read(CONFIG).catch(() => null);
62
+ let existing = {};
63
+ if (had?.ok && had.body) { try { existing = JSON.parse(had.body) || {}; } catch { existing = {}; } }
64
+ const config = forumConfig(existing, opts);
65
+ await writeConfig(storage, config);
66
+ log(`forum ${config.handle} initialised with ${config.categories.length} categor${config.categories.length === 1 ? 'y' : 'ies'}`);
67
+ return { storage, config, plain };
68
+ }
69
+
70
+ /** The forum's rows at a Gateway: its own and one per category, all writing into its one inbox. */
71
+ export const gatewayRows = (config, plain) => [
72
+ { handle: config.handle, podHome: plain.home, actorUrl: plain.actor, kind: 'application' },
73
+ ...(config.categories || []).map(c => ({ handle: c.slug, podHome: plain.category(c.slug).home,
74
+ actorUrl: plain.category(c.slug).actor, kind: 'group' })),
75
+ ];
76
+
77
+ /**
78
+ * Attach the forum at a Gateway: each row fronted, its receipts' secret kept.
79
+ * `fetch` carries the owner's proof (the pod session). Returns what the config
80
+ * records under `gateway`, and the handles.
81
+ */
82
+ export async function attachRows({ fetch: f, front, config, plain }, { log = () => {} } = {}) {
83
+ const origin = String(front).replace(/\/+$/u, '');
84
+ const secrets = {};
85
+ const rows = gatewayRows(config, plain);
86
+ for (const row of rows) {
87
+ const res = await f(`${origin}/api/attach`, {
88
+ method: 'POST', headers: { 'content-type': 'application/json' },
89
+ body: JSON.stringify({ ...row, fronted: true, inboxUrl: plain.inbox }),
90
+ });
91
+ const d = await res.json().catch(() => ({}));
92
+ if (res.status !== 201 || !d.hmacSecret) throw new Error(`attach ${row.handle} at ${origin}: HTTP ${res.status}${d.error ? ' ' + d.error : ''}`);
93
+ secrets[row.handle] = String(d.hmacSecret);
94
+ log(`attached @${row.handle}@${new URL(origin).host}`);
95
+ }
96
+ return { gateway: { front: origin, mode: 'trust', secrets }, handles: rows.map(r => r.handle) };
97
+ }
98
+
99
+ /**
100
+ * Let a Gateway keep the forum (or stop): every row, the categories first and
101
+ * the forum's own row last, because turning the forum's row on is what starts
102
+ * its first run, and that run wants every category kept already. Returns the
103
+ * keeper's WebID.
104
+ */
105
+ export async function keepRows({ fetch: f, front, config, on = true }) {
106
+ const origin = String(front).replace(/\/+$/u, '');
107
+ const forumHandle = config.handle;
108
+ const handles = [...(config.categories || []).map(c => c.slug), forumHandle];
109
+ let keeper = null;
110
+ for (const handle of handles) {
111
+ const res = await f(`${origin}/api/keeper`, {
112
+ method: 'POST', headers: { 'content-type': 'application/json' },
113
+ body: JSON.stringify({ handle, on, ...(handle === forumHandle ? {} : { forum: forumHandle }) }),
114
+ });
115
+ const d = await res.json().catch(() => ({}));
116
+ if (!res.ok) throw new Error(`${origin} would not ${on ? 'keep' : 'let go of'} @${handle}: ${d.error || res.status}`);
117
+ keeper = d.keeper || keeper;
118
+ }
119
+ return { keeper: on ? keeper : null, handles, on };
120
+ }
121
+
122
+ /**
123
+ * The forum's rules stated with the keeper's name beside the owner's (or
124
+ * without it): the containers and what they inherit. For a forum that has
125
+ * members and readers already, the host's own restatement (keep.mjs) also
126
+ * rewrites the reader lists; a forum just made has none.
127
+ */
128
+ export async function nameKeeperInRules(remote, { config, plain, keeper = null, ownerWebId = null }) {
129
+ remote.keepers = keeper ? [keeper] : [];
130
+ remote.aclOwner = ownerWebId || remote.aclOwner || null;
131
+ await provisionForum(remote, plain, { moderatorWebIds: config.moderatorWebIds || [] });
132
+ await restateRules(remote, plain);
133
+ for (const c of config.categories || []) {
134
+ const urls = plain.category(c.slug);
135
+ const members = (config.membersOnly || []).includes(c.slug) ? (config.memberWebIds?.[c.slug] || []) : null;
136
+ await provisionCategory(remote, urls, { memberWebIds: members });
137
+ await restateRules(remote, urls);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * A forum made at a Gateway's sign-up page, start to finish: config and
143
+ * containers on the pod, its rows at the Gateway, the Gateway named as its
144
+ * keeper in every rule, and the keeper's first run started by the last
145
+ * switch. `remote` is the owner's pod transport, `fetch` their pod session's
146
+ * fetch (the Gateway proves the owner by it). Returns the forum's handle and
147
+ * the Gateway it lives at.
148
+ */
149
+ export async function setUpForumAtGateway({ remote, storageFor, fetch: f, front, ownerWebId, ...opts }, { log = () => {}, onStep = () => {} } = {}) {
150
+ // `onStep(key, state)`: write, attach, keep; running then ok.
151
+ onStep('write', 'running');
152
+ const { storage, config, plain } = await writeForumConfig(remote, storageFor, opts, { log });
153
+ onStep('write', 'ok');
154
+ onStep('attach', 'running');
155
+ const { gateway, handles } = await attachRows({ fetch: f, front, config, plain }, { log });
156
+ await writeConfig(storage, { ...config, gateway, republish: true });
157
+ onStep('attach', 'ok');
158
+ onStep('keep', 'running');
159
+ const { keeper } = await keepRows({ fetch: f, front, config, on: true });
160
+ await nameKeeperInRules(remote, { config, plain, keeper, ownerWebId });
161
+ onStep('keep', 'ok');
162
+ return { handle: config.handle, front: gateway.front, handles, keeper };
163
+ }