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.
package/README.md ADDED
@@ -0,0 +1,195 @@
1
+ # FediPod-BB
2
+
3
+ A forum whose record lives on a Solid pod, as ActivityPub documents in the
4
+ shapes the Fediverse's boards use: each category is a Group actor (FEP-1b12),
5
+ each topic is a context collection (FEP-7888), each post is a Page (with a title, as Lemmy's are) or a
6
+ Note that names its topic and its category.
7
+
8
+ This package holds the pod layout, the writers for it, the host, and the
9
+ website: where every document lives, how a topic's pages are written and
10
+ sealed, how a member's post is cached for readers, the process that runs
11
+ the forum from a moderator's machine, and the page that shows the forum.
12
+ A moderator's console is part of that page: the queue of reports, held posts
13
+ and asks, and the settings.
14
+ The queue shows what needs a moderator: a report, a held post, a join
15
+ request, and a request of a moderator's that did not take. The design and the phases are in
16
+ `claude/plans/fedipod-bb.md` at the repository root.
17
+
18
+ ```
19
+ node --test packages/fedipod-bb/test/*.test.mjs
20
+ ```
21
+
22
+ ## Hosting a forum
23
+
24
+ ```
25
+ npm install -g fedipod-bb
26
+ fedipod-bb credential --home DIR --email you@example.org --pod https://forum.example/
27
+ fedipod-bb init --home DIR --handle forum --name "The Forum" \
28
+ --category gardening:Gardening --category compost:Compost --moderator <actor id>
29
+ fedipod-bb start --home DIR
30
+ ```
31
+
32
+ `credential` mints the forum's pod credential at the pod's server, with the
33
+ account's password asked at the terminal, and saves it as
34
+ `DIR/credential.json`; nothing is written to the pod.
35
+
36
+ `init` writes the forum's config and containers. `start` publishes every
37
+ actor the first time, then drains the forum's one inbox, hands each activity
38
+ to the category it names, places carried posts in topics, and carries them
39
+ to the category's followers. Several moderators run `start` on their own
40
+ machines against the same pod: one hosts, the others watch, and when the
41
+ host stops another takes over within fifteen minutes. A public
42
+ `ap/heartbeat` says when the forum was last hosted. `status` prints
43
+ what the pod's state says without hosting.
44
+
45
+ Every category is a FediPod group: joining is a Follow, a member's public
46
+ post addressed to the category is carried (FEP-1b12) and then placed in its
47
+ topic — the one its `context` names, the one its reply chain leads to, or a
48
+ new one with the post as its opening (FEP-7888). A post from a non-member is
49
+ not carried and opens nothing.
50
+
51
+ ## Layout
52
+
53
+ Under one root, `fedipod-bb/`, on the forum's pod:
54
+
55
+ | path | what |
56
+ |---|---|
57
+ | `ap/actor` | the forum's own actor, an `Application` |
58
+ | `ap/inbox/` | the one inbox every category names as its own |
59
+ | `ap/categories` | the categories, an `OrderedCollection` of their actor ids |
60
+ | `ap/administrators` | the moderators' actor ids |
61
+ | `ap-state/` | owner-only: the forum's config, keys and lease |
62
+ | `c/<slug>/` | one FediPod group root per category, unchanged from a group's |
63
+ | `c/<slug>/ap/topics` | the category's topics, newest first, paged |
64
+ | `c/<slug>/ap/topic/<tid>` | a topic: the context collection, with pages `-1`, `-2`, … oldest first |
65
+ | `c/<slug>/ap/cache/<key>` | a readable copy of a member's post, for the website |
66
+ | `c/<slug>/ap-state/topics.json`, `topics/<tid>.json` | owner-only: the topic record |
67
+
68
+ A category's `ap/actor`, `ap/outbox`, `ap/followers`, `ap/moderators` and
69
+ `ap/featured` are what a FediPod group publishes today; the forum adds the
70
+ topic documents beside them. Posts are named in a topic by their authors'
71
+ own ids; the cached copy is for readers in a browser and is never listed in
72
+ a collection.
73
+
74
+ ## The website
75
+
76
+ `site/` is a static page. Staged by `scripts/stage-site.mjs` under `/bb/`
77
+ on the fedipod.net site, it opens a forum attached there at
78
+ `/bb/?forum=<handle>`, reading everything through that site's own
79
+ `/u/<handle>/` addresses; `/bb/?pod=https://…/fedipod-bb/` reads a forum
80
+ from its pod directly. Where the site has `bb.<domain>` as a domain alias,
81
+ the same page answers there for every path, with the forum named by the
82
+ first path segment: `https://bb.fedipod.net/<handle>/`. It still reads
83
+ through `<domain>`, where the handles live (the hosts are `BB_HOSTS` in the
84
+ staging script). Anyone reads.
85
+ A topic whose newest post arrived since the reader last opened it is marked
86
+ `New` on the index. The mark is the reader's own: one entry per topic in their
87
+ browser's storage, written nowhere else and sent nowhere - a public forum has
88
+ no place to keep who read what, and no business keeping it. Arriving at a
89
+ forum for the first time reads everything before that moment, so a new reader
90
+ does not meet a page of `New`.
91
+
92
+ The page is built to WCAG 2.2 AA: a skip link, named landmarks, a label on
93
+ every control, an `aria-label` on each post's buttons naming whose post they
94
+ act on, `aria-current` on the category in view, `scope` on the index's
95
+ headers, a polite live region for what the page has just done, and a reply
96
+ box that takes the keyboard when it opens and returns it when it closes.
97
+ The front page is an index of the forum's newest posts, across every
98
+ category: topic, category, author, date and the topic's reply count, one
99
+ line each. The forum publishes that index itself (`ap/latest`, the copies it
100
+ already holds, newest first, capped at fifty), so the page makes one request
101
+ for the list rather than walking every category. The category chips filter
102
+ it; a topic's name opens the thread at that post. Pinned topics sort first -
103
+ a category's `featured` collection for a pin within it, the forum actor's own
104
+ for one that holds everywhere.
105
+
106
+ A thread is a tree: a reply sits under the post it answers, six deep. Posts
107
+ are written in Markdown (`site/markdown.mjs`, no dependency, escaped before
108
+ it is marked up) and carry their source alongside, so an edit reopens what
109
+ was typed. A topic is named by the activity that opens it; a post has no
110
+ title.
111
+
112
+ Each post offers Reply, Share and Report, and its author Edit and Delete; a
113
+ report is queued for the moderators. Under a topic's title a moderator gets
114
+ rename, pin, pin site-wide and delete. Every moderator request is published
115
+ at the moderator's own pod and fetched back from there before the forum acts
116
+ on it (FEP-fe34), which is what makes a button on a public page safe.
117
+ A post can be voted for: a `Like` to the category, its `Undo` to take it
118
+ back, counted per person and published with the post as AS2 `likes`. The
119
+ index sorts by newest or by votes, and searches what it is holding - topic
120
+ names, authors and the words of the posts. A byline opens that person's
121
+ posts in this forum.
122
+
123
+ A moderator's queue lives at `<pod>/fedipod-bb/mod/queue.json`, with a
124
+ record of what was done beside it in `log.json`. Neither is public: the
125
+ container's access rule names the moderators' WebIDs, and the page reads it
126
+ with the moderator's own login rather than through the Gateway. From it a
127
+ moderator lets a held post through (`Accept`), turns it away (`Reject`) or
128
+ bans its author (`Block`) - each published at their own pod and checked
129
+ there, like every other ask.
130
+
131
+ A moderator gets a Settings page: rename the forum, rename a category,
132
+ manage moderators and members, and make a new category. Each row carries
133
+ Open and Private. Every change is an ordinary activity - a `Create` of a
134
+ `Group`, an `Update`, an `Add` or a `Remove` - published at the moderator's
135
+ own pod and fetched back before it is applied.
136
+
137
+ **Open or private belongs to the category, and only the forum changes it.**
138
+ Joining does not, being admitted does not, and being removed does not. On
139
+ the wire it is `manuallyApprovesFollowers` on the Group.
140
+
141
+ A private category means all of this, and no more:
142
+
143
+ - Its trees are readable by named WebIDs only. The actor stays public, since
144
+ a server that cannot read the actor cannot deliver to the category.
145
+ - It publishes `c/<slug>/ap/members`, the WebIDs that may read it, readable
146
+ only by those same WebIDs. The forum's own WebID is one of them, because
147
+ the forum fetches every post back from its author's pod.
148
+ - Every join waits for a moderator, and admitting somebody requires a WebID.
149
+ A follower without one is let go with a `Reject`: carrying posts to
150
+ somebody refused the pages would not be private at all.
151
+ - A member's post is addressed to the category's followers collection rather
152
+ than to the public, and is written into a container of its own on the
153
+ member's pod whose access rule names the same reader list. One container
154
+ per category, rewritten on each post, so one rule covers that member's
155
+ whole history there.
156
+
157
+ What it does NOT mean, and the page says so in the box before anyone writes:
158
+ an ejected reader keeps a given author's older posts until that author next
159
+ writes; somebody admitted today reads the whole history; posts written while
160
+ the category was open stay public; pod operators hold the plaintext; and any
161
+ admitted member can copy anything.
162
+
163
+ The command-line equivalents are `--members-only <slug>` and
164
+ `--member <slug>:<webid>`. A draft FEP for the layout and this model is in
165
+ [fep-draft.md](fep-draft.md).
166
+ To reply, a reader signs in once with a
167
+ Mastodon account: the page registers itself on their server, sends them to
168
+ approve it, and keeps the token in their browser; a reply is posted from
169
+ their account with the category mentioned, and answers the thread's last
170
+ post as their server resolves it. The reply shows in the thread once the
171
+ forum's host has placed it; until then the reader sees their own copy marked
172
+ as waiting. A reader with a pod account is pointed at FediPod and the
173
+ category's address; a reader with nothing is pointed at sign-up.
174
+
175
+ ## Attaching to a Gateway
176
+
177
+ ```
178
+ node packages/fedipod-bb/bin/fedipod-bb.mjs attach --home DIR --front https://fedipod.net
179
+ ```
180
+
181
+ Takes `@<handle>@fedipod.net` for the forum and one address per category,
182
+ each a row of its own at the Gateway pointing at that category's tree on the
183
+ pod and at the forum's one inbox. Deliveries arrive verified at the front;
184
+ the next `start` republishes every actor under its front address.
185
+
186
+ A post is voted up or down, the way Lemmy federates a vote: a `Like` for up,
187
+ a `Dislike` for down, and the `Undo` of whichever was cast to take it back.
188
+ One person has one vote, so voting the other way is a changed mind rather
189
+ than a second vote. The up count is AS2's `likes` on the post's copy.
190
+ ActivityStreams has no property for a down count and none is invented: it is
191
+ an ordinary `Collection` published beside the copy, at that copy's address
192
+ with `-dislikes` after it, and taken down again when it reaches nought.
193
+ The copy also carries the count itself, as `dislikes` shaped like `likes`,
194
+ which is the forum's own word; the page reads it there rather than asking
195
+ for a document beside every post it shows.
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ // fedipod-bb.mjs — run a forum from this machine.
3
+ //
4
+ // fedipod-bb credential --home DIR --email you@example.org --pod https://forum.example/ [--issuer URL]
5
+ // Mint the forum's pod credential at the pod's server (the password is
6
+ // asked at the terminal) and save it as DIR/credential.json.
7
+ // fedipod-bb init --home DIR --handle forum --name "The Forum" \
8
+ // [--moderator-webid https://you.example/profile/card#me] \
9
+ // --category gardening:Gardening --category compost:Compost [--moderator <actor>]
10
+ // Writes the forum's config and containers to the pod; publishes nothing yet.
11
+ // fedipod-bb start --home DIR
12
+ // Host the forum from here. Publishes every actor on first start, then
13
+ // drains the forum's inbox, places posts in topics and carries them.
14
+ // Several moderators may run this on their own machines; one acts, the
15
+ // others watch and take over when it stops.
16
+ // fedipod-bb status --home DIR
17
+ // What the forum's state says, without hosting.
18
+ // fedipod-bb attach --home DIR --front https://fedipod.net
19
+ // Take addresses at a Gateway: @<handle>@<front> for the forum and one
20
+ // per category. Deliveries arrive verified at the front and are written
21
+ // into the forum's inbox; every actor is republished with its front ids
22
+ // on the next start.
23
+
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import { ForumAgent } from '../src/forum-agent.mjs';
27
+
28
+ const args = process.argv.slice(2);
29
+ const cmd = args[0];
30
+ const flag = (name) => { const i = args.indexOf('--' + name); return i >= 0 ? args[i + 1] : null; };
31
+ const flags = (name) => args.flatMap((a, i) => (a === '--' + name && args[i + 1] ? [args[i + 1]] : []));
32
+ const home = flag('home') || process.env.FEDIPOD_BB_HOME;
33
+ if (!home) { console.error('--home DIR is required'); process.exit(2); }
34
+
35
+ const logFile = path.join(home, 'forum.log');
36
+ const log = (...a) => {
37
+ const line = `${new Date().toISOString()} ${a.join(' ')}`;
38
+ console.log('[bb]', ...a);
39
+ try { fs.appendFileSync(logFile, line + '\n'); } catch { /* logging never throws */ }
40
+ };
41
+
42
+ // --reply-policy open|review, checked here so a typo does not quietly become
43
+ // the strictest reading of it.
44
+ const replyPolicy = () => {
45
+ const said = String(flag('reply-policy') || '').toLowerCase();
46
+ if (said !== 'open' && said !== 'review') {
47
+ console.error('--reply-policy takes open or review');
48
+ process.exit(2);
49
+ }
50
+ return said;
51
+ };
52
+
53
+ if (cmd === 'credential') {
54
+ const email = flag('email');
55
+ const pod = flag('pod');
56
+ if (!email || !pod) { console.error('--email and --pod are required'); process.exit(2); }
57
+ const { mintForumCredential } = await import('../src/credential.mjs');
58
+ try {
59
+ const file = await mintForumCredential({ email, pod, home, issuer: flag('issuer'), root: flag('root') || 'fedipod-bb/' });
60
+ console.log(`credential minted and saved to ${file}`);
61
+ } catch (e) { console.error(e.message); process.exit(1); }
62
+ } else if (cmd === 'init') {
63
+ const handle = flag('handle');
64
+ if (!handle) { console.error('--handle is required'); process.exit(2); }
65
+ const categories = flags('category').map(c => {
66
+ const [slug, name] = c.split(':');
67
+ return { slug, name: name || slug };
68
+ });
69
+ const agent = new ForumAgent({ home, log });
70
+ const cfg = await agent.init({
71
+ handle, name: flag('name') || handle, categories, moderators: flags('moderator'),
72
+ // A moderator's WebID, so the pod itself can let them read the queue;
73
+ // their actor id is what the wire uses and cannot be granted access.
74
+ moderatorWebIds: flags('moderator-webid'),
75
+ // A members-only category, and who may read it: --members-only <slug>
76
+ // and --member <slug>:<webid>. Only a WebID can be named; a follower
77
+ // from Mastodon has none, and serving them is the Server build's job.
78
+ membersOnly: flags('members-only'),
79
+ memberWebIds: flags('member').reduce((m, v) => {
80
+ const at = v.indexOf(':');
81
+ if (at < 1) return m;
82
+ const slug = v.slice(0, at);
83
+ (m[slug] ||= []).push(v.slice(at + 1));
84
+ return m;
85
+ }, {}),
86
+ approveJoins: args.includes('--approve-joins'), review: args.includes('--review'),
87
+ // What becomes of a post from somebody who has not joined: `open` takes
88
+ // the post as the joining, `review` holds it for a moderator. A private
89
+ // category holds it whatever this says.
90
+ ...(flag('reply-policy') ? { replyPolicy: replyPolicy() } : {}),
91
+ });
92
+ console.log(JSON.stringify(cfg, null, 2));
93
+ } else if (cmd === 'start') {
94
+ const { runForum } = await import('../src/run.mjs');
95
+ const agent = await runForum({ home, log });
96
+ if (!agent) process.exit(1);
97
+ } else if (cmd === 'attach') {
98
+ const front = flag('front');
99
+ if (!front) { console.error('--front <https://gateway-origin> is required'); process.exit(2); }
100
+ const agent = new ForumAgent({ home, log });
101
+ if (!await agent.connect({ act: false })) { console.error('nothing to attach — run init first'); process.exit(1); }
102
+ const r = await agent.attach({ front });
103
+ console.log(`attached at ${r.front}: ${r.handles.map(h => '@' + h).join(', ')} — start the forum to publish its new addresses`);
104
+ process.exit(0);
105
+ } else if (cmd === 'status') {
106
+ const agent = new ForumAgent({ home, log: () => {} });
107
+ const up = await agent.connect({ act: false });
108
+ console.log(JSON.stringify(up ? agent.status() : { mode: 'unconfigured' }, null, 2));
109
+ process.exit(0);
110
+ } 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]');
112
+ process.exit(2);
113
+ }
package/fep-draft.md ADDED
@@ -0,0 +1,110 @@
1
+ ---
2
+ slug: "xxxx"
3
+ authors: Jeff Zucker <https://jeffz.solidcommunity.net/profile/card#me>
4
+ status: DRAFT
5
+ dateReceived: 2026-09-16
6
+ ---
7
+ # A discussion forum whose record is a Solid pod
8
+
9
+ ## Summary
10
+
11
+ A forum — categories, topics, posts, moderation — kept as ActivityPub
12
+ documents on a Solid pod and served from static pages. No forum database and no
13
+ forum server. Readers read the pod. Members write to their own pods. A host
14
+ run by the moderators drains one inbox and writes the record back.
15
+
16
+ Existing FEPs cover the wire. This says where the documents live and how a
17
+ category is made private with the pod's own access control.
18
+
19
+ ## Requirements
20
+
21
+ The key words MUST, SHOULD and MAY are to be interpreted as in RFC 2119.
22
+
23
+ ## The layout
24
+
25
+ One container, by convention `fedipod-bb/`, on the forum's pod.
26
+
27
+ ap/actor the site, an Application
28
+ ap/inbox/ ONE inbox; every category names it as its sharedInbox
29
+ ap/categories OrderedCollection of category actor ids
30
+ ap/administrators FEP-baf5
31
+ ap-state/ owner-only: config, lease, keys
32
+ c/<slug>/ one per category, an ordinary group's tree
33
+ ap/actor a Group (FEP-1b12)
34
+ ap/followers its membership
35
+ ap/moderators its roster; the actor's attributedTo
36
+ ap/members who may READ it; present only when it is private
37
+ ap/topics(-N) OrderedCollection of topic ids
38
+ ap/topic/<tid>(-N) one topic: a context collection (FEP-7888)
39
+ ap/cache/<sha16> the forum's readable copy of a member's post
40
+
41
+ A topic is an `OrderedCollection` whose `attributedTo` is the category, whose
42
+ `orderedItems` are the authors' own post ids, and whose `name` is the topic's
43
+ title — given by the person opening it, in the `name` of their `Create`, and
44
+ never taken from the post. Every post in it carries `context` naming it.
45
+
46
+ Posts are named, not copied, in the record: the author's server stays the
47
+ authority on edits and deletions (FEP-fe34). Because a browser cannot fetch
48
+ from a stranger's server, the forum ALSO writes a verified copy under
49
+ `ap/cache/`, replaced by a `Tombstone` (FEP-4f05) when the original goes.
50
+
51
+ ## Open and private
52
+
53
+ Open or private is a property of the category, set by its administrators. It
54
+ MUST NOT change as a side effect of anybody joining, being admitted, or being
55
+ removed. On the wire it is `manuallyApprovesFollowers` on the Group.
56
+
57
+ A private category:
58
+
59
+ 1. Its trees are readable by named WebIDs only; the actor stays public, since
60
+ a server that cannot read the actor cannot deliver to it.
61
+ 2. It publishes `ap/members`, an OrderedCollection of the WebIDs that may read
62
+ it, itself readable only by those WebIDs. The forum's own WebID is among
63
+ them, because the forum dereferences each post at its author's pod.
64
+ 3. Every join waits for a moderator, and admitting somebody requires a WebID.
65
+ A follower without one MUST be let go with a `Reject`: carrying posts to
66
+ someone who is refused the pages is not privacy.
67
+ 4. Members address their posts to the category's followers collection, not to
68
+ `as:Public`. A group MUST NOT otherwise carry a non-public post; a private
69
+ group carries them because its membership is exactly the set already
70
+ permitted to read them.
71
+ 5. The member's own copy is written into a container on the member's pod whose
72
+ access rule names the same reader list. One container per category, so one
73
+ rule covers that member's whole history there and is rewritten with each
74
+ post.
75
+
76
+ ## Votes
77
+
78
+ A vote is a `Like` or a `Dislike` addressed to the category, and the `Undo` of
79
+ whichever was cast to take it back; one actor MAY hold one of them per object,
80
+ so the second replaces the first. The up count is published as the object's
81
+ `likes`. ActivityStreams has no property for a down count: implementations
82
+ MUST NOT invent one. This publishes it as a `Collection` beside the object's
83
+ cached copy, at that copy's address with `-dislikes` after it, absent at nought.
84
+
85
+ ## Security considerations
86
+
87
+ - **Revocation is not uniform.** The forum revokes its own copies at once.
88
+ Members' own copies are behind rules only those members can write, so an
89
+ ejected reader keeps a given author's older posts until that author next
90
+ writes. Implementations SHOULD rewrite the rule on every post.
91
+ - **Admission is retroactive.** A new member reads the whole history, because
92
+ the forum's copies are re-granted as a set.
93
+ - **Not confidentiality.** This is access control. Pod operators, and any
94
+ gateway a delivery passes through, hold the plaintext. Any admitted member
95
+ can copy anything. A category MUST NOT claim more than that.
96
+ - **Nothing is retroactive at the switch.** Posts written while a category was
97
+ open were published to the world and stay so.
98
+ - An unsigned append to a public inbox proves nothing, so a moderator's
99
+ request is published at the moderator's own pod and fetched back from there
100
+ before it is applied (FEP-fe34).
101
+
102
+ ## References
103
+
104
+ FEP-1b12 group federation · FEP-7888 contexts · FEP-f15d relocation ·
105
+ FEP-4f05 tombstones · FEP-fe34 origin · FEP-7458 replies · FEP-b2b8 long-form ·
106
+ FEP-baf5 administrators · FEP-044f quotes · W3C Web Access Control · Solid-OIDC
107
+
108
+ ## Copyright
109
+
110
+ CC0 1.0 Universal.
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "fedipod-bb",
3
+ "version": "0.2.0",
4
+ "description": "FediPod-BB: a forum website whose record lives on a Solid pod.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "src/index.mjs",
8
+ "bin": {
9
+ "fedipod-bb": "bin/fedipod-bb.mjs"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "src",
14
+ "site",
15
+ "README.md",
16
+ "fep-draft.md"
17
+ ],
18
+ "scripts": {
19
+ "pretest": "node scripts/link-fedipod.mjs",
20
+ "test": "node --test test/*.test.mjs"
21
+ },
22
+ "dependencies": {
23
+ "fedipod": "^1.42.0",
24
+ "fediverse-session": "^0.2.1"
25
+ }
26
+ }