fedipod 1.42.0 → 1.44.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 +16 -0
- package/lib/core/deliver.mjs +11 -1
- package/lib/core/intake/group.mjs +10 -11
- package/lib/gateway/copy.mjs +5 -2
- package/lib/gateway/front-core.mjs +29 -10
- package/lib/gateway/front-pages.mjs +2 -4
- package/lib/gateway/gateway-core.mjs +1 -1
- package/lib/gateway/held-mail.mjs +10 -0
- package/lib/gateway/keeper.mjs +3 -0
- package/package.json +3 -3
- package/run-agent.mjs +10 -15
- package/scripts/check-netlify-bundles.mjs +4 -0
- package/scripts/check-pod-calls.mjs +1 -1
- package/scripts/start.mjs +13 -0
- package/web/app/boot.mjs +65 -7
- package/web/app/dist/boot.js +764 -10
- package/web/app/dist/boot.js.map +4 -4
- package/web/app/forum-form.mjs +38 -0
- package/web/app/forum-support.mjs +11 -0
- package/web/app/index.html +29 -4
- package/web/app/signup-forum.mjs +52 -0
- package/web/app/signup.mjs +2 -2
- package/web/front/new-account.html +8 -16
- package/web/front/new-account.js +3 -7
- package/web/front/install.sh +0 -69
package/README.md
CHANGED
|
@@ -34,6 +34,16 @@ This README is about the `FediPod BrowserAgent`. There are also a number of [oth
|
|
|
34
34
|
|
|
35
35
|
From now on, anyone can open your profile at `https://fedipod.net/@handle@provider`, signed in or not.
|
|
36
36
|
|
|
37
|
+
## Getting a forum
|
|
38
|
+
|
|
39
|
+
The same page makes a forum. Choose **A forum** as the first thing on it. A
|
|
40
|
+
forum has a pod of its own, so make a new pod for it at your provider (or
|
|
41
|
+
sign in with one that holds nothing else), then give the forum a handle, a
|
|
42
|
+
name and its categories, and the address at fedipod.net you will moderate it
|
|
43
|
+
as. fedipod.net runs the forum from then on; nothing runs on your machine.
|
|
44
|
+
Its page is at `https://bb.fedipod.net/<handle>/` and its address is
|
|
45
|
+
`@handle@fedipod.net`, with one address per category.
|
|
46
|
+
|
|
37
47
|
## What you can do
|
|
38
48
|
|
|
39
49
|
There are two clients, both served by fedipod.net:
|
|
@@ -141,6 +151,12 @@ not change.
|
|
|
141
151
|
- [FediPod Groups](groups.md): hosting a discussion group of Fediverse and Bluesky users from a pod.
|
|
142
152
|
- [FediPod Gateway](gateway.md): A thin, always-on door running on Netlify or any small host that provides a middleman between a pod and the single-user agents. (only needed with `BrowserAgent` and `DeviceAgent`)
|
|
143
153
|
|
|
154
|
+
There are two ways to run FediPod: in a browser at a Gateway such as
|
|
155
|
+
fedipod.net, with nothing installed, or as a Gateway of your own, deployed
|
|
156
|
+
from this repository ([netlify/README.md](netlify/README.md)). Cloning the
|
|
157
|
+
repository and running it is not one of them: `npm start` only says so. The
|
|
158
|
+
DeviceAgent is deprecated; nothing new is built for it.
|
|
159
|
+
|
|
144
160
|
|
|
145
161
|
## Acknowledgements
|
|
146
162
|
|
package/lib/core/deliver.mjs
CHANGED
|
@@ -61,9 +61,14 @@ export class Deliverer {
|
|
|
61
61
|
// passive: signing-only (viewer-mode agents) — no queue drain timer, so a
|
|
62
62
|
// read-only agent never mutates shared delivery state. startQueue() flips
|
|
63
63
|
// it live when a viewer is promoted to active.
|
|
64
|
+
// defer: nothing is sent now; every delivery goes on the queue, signed, for
|
|
65
|
+
// drainQueue() to make. For a process with seconds to answer — a door
|
|
66
|
+
// placing a post as it lands — which records what to send and leaves the
|
|
67
|
+
// sending to the run it starts.
|
|
64
68
|
constructor({ store, keyId, rsaPrivate, actorId = null, edPrivate = null, proofKeyId = null,
|
|
65
|
-
log = console.log, passive = false, onGone = null }) {
|
|
69
|
+
log = console.log, passive = false, defer = false, onGone = null }) {
|
|
66
70
|
this.store = store;
|
|
71
|
+
this.defer = defer;
|
|
67
72
|
// Called after followers at a gone inbox are dropped: the agent republishes
|
|
68
73
|
// its followers collection.
|
|
69
74
|
this.onGone = onGone;
|
|
@@ -205,6 +210,7 @@ export class Deliverer {
|
|
|
205
210
|
// Proved before anything else, so the copy that goes on the queue is the
|
|
206
211
|
// copy that was signed — a retry days later must not post a bare activity.
|
|
207
212
|
const signed = await this.proofed(activity);
|
|
213
|
+
if (this.defer) { this._enqueue({ inbox, activity: signed, attempts: 0, nextAt: Date.now() }); return; }
|
|
208
214
|
if (this._queueIfCooling(inbox, signed)) return;
|
|
209
215
|
const [result] = await this.deliverManyNow([{ inbox, activity: signed }]);
|
|
210
216
|
await this._settle(inbox, signed, result);
|
|
@@ -273,6 +279,10 @@ export class Deliverer {
|
|
|
273
279
|
// by the next.
|
|
274
280
|
const targets = [...new Set(inboxes)].filter((inbox) => !this._blockedInbox(inbox))
|
|
275
281
|
.map((inbox) => ({ inbox, activity: signed }));
|
|
282
|
+
if (this.defer) {
|
|
283
|
+
for (const t of targets) this._enqueue({ inbox: t.inbox, activity: signed, attempts: 0, nextAt: Date.now() });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
276
286
|
for (let i = 0; i < targets.length; i += this.batchSize) {
|
|
277
287
|
const chunk = targets.slice(i, i + this.batchSize).filter((t) => !this._queueIfCooling(t.inbox, signed));
|
|
278
288
|
if (!chunk.length) continue;
|
|
@@ -171,6 +171,16 @@ export async function amplify(intake, noteId, { approved = false, activity = nul
|
|
|
171
171
|
serial: intake.serial++,
|
|
172
172
|
audience: intake.urls.actor,
|
|
173
173
|
});
|
|
174
|
+
// What the group is about to carry is what the group's conversation holds.
|
|
175
|
+
// A forum places the post in its topic here, BEFORE the carry: the topic is
|
|
176
|
+
// what the category vouches for (FEP-7888), and a place that will not take
|
|
177
|
+
// the post — a locked topic — answers false and nothing is carried. Both
|
|
178
|
+
// the Announce and what its author sent: a forum reads the author's own
|
|
179
|
+
// activity — the name they gave the topic they were opening is on that one.
|
|
180
|
+
if (intake.onCarry) {
|
|
181
|
+
const placed = await intake.onCarry({ noteId, actor: s.actor, activity: act, sent: activity });
|
|
182
|
+
if (placed === false) { intake.log(`not amplified — ${noteId} has no place here`); return; }
|
|
183
|
+
}
|
|
174
184
|
await intake.deliverer.deliverToAll(inboxes, act);
|
|
175
185
|
// Marked carried before recorded: a failed outbox write costs one missing
|
|
176
186
|
// entry, a failed status write would carry the same post twice.
|
|
@@ -180,17 +190,6 @@ export async function amplify(intake, noteId, { approved = false, activity = nul
|
|
|
180
190
|
if (!intake.config.private) await intake.publisher.recordOutbox(act);
|
|
181
191
|
intake.store.setPending(intake.store.getPending().filter(p => p.noteId !== noteId));
|
|
182
192
|
intake.log(`amplified ${noteId} → ${inboxes.length} inbox(es)`);
|
|
183
|
-
// What the group carried is what the group's conversation holds. A forum
|
|
184
|
-
// places the post in its topic here — after the carry, so only what the
|
|
185
|
-
// category vouched for is in the topic (FEP-7888 ownership). Its failure
|
|
186
|
-
// never undoes the carry.
|
|
187
|
-
if (intake.onCarried) {
|
|
188
|
-
// Both: what the group said (the Announce) and what its author sent. A
|
|
189
|
-
// forum reads the author's own activity — the name they gave the topic
|
|
190
|
-
// they were opening is on that one, and on no other.
|
|
191
|
-
await intake.onCarried({ noteId, actor: s.actor, activity: act, sent: activity })
|
|
192
|
-
.catch(e => intake.log(`after the carry of ${noteId}: ${e.message}`));
|
|
193
|
-
}
|
|
194
193
|
// The same carry, shown natively to the group's Bluesky followers.
|
|
195
194
|
await intake.bskyGroup?.mirrorCarry(s)
|
|
196
195
|
.catch(e => intake.log(`bluesky mirror of the carry failed: ${e.message}`));
|
package/lib/gateway/copy.mjs
CHANGED
|
@@ -149,9 +149,12 @@ export async function holds(kv, h, holder) {
|
|
|
149
149
|
export class CopyStorage {
|
|
150
150
|
// `podNames`: which of the pod-only documents to list (all, by default). The
|
|
151
151
|
// gateway's own agents need only the key.
|
|
152
|
-
|
|
152
|
+
// `fence`: the copy whose lease decides who may write this one. A forum's
|
|
153
|
+
// categories are fenced by the forum's copy, since one lease runs the forum.
|
|
154
|
+
constructor(kv, handle, { holder, pod = null, onRefused = null, podNames = null, fence = null }) {
|
|
153
155
|
this.kv = kv;
|
|
154
156
|
this.h = account(handle);
|
|
157
|
+
this.fence = fence ? account(fence) : this.h;
|
|
155
158
|
this.holder = holder;
|
|
156
159
|
this.pod = pod;
|
|
157
160
|
this.onRefused = onRefused;
|
|
@@ -186,7 +189,7 @@ export class CopyStorage {
|
|
|
186
189
|
|
|
187
190
|
// A refusal is an answer, not a hiccup: the lease is someone else's.
|
|
188
191
|
async _fenced() {
|
|
189
|
-
if (await holds(this.kv, this.
|
|
192
|
+
if (await holds(this.kv, this.fence, this.holder)) return null;
|
|
190
193
|
this.onRefused?.();
|
|
191
194
|
return { ok: false, retry: false, why: 'another agent holds this account now', lost: true };
|
|
192
195
|
}
|
|
@@ -740,11 +740,6 @@ async function route(request, ctx) {
|
|
|
740
740
|
return { status: 200, headers: { 'content-type': 'text/javascript' }, body: ctx.authBundle };
|
|
741
741
|
}
|
|
742
742
|
|
|
743
|
-
// The installer: curl -fsSL https://<host>/install | sh
|
|
744
|
-
if (pathname === '/install') {
|
|
745
|
-
if (!ctx.installScript) return notFound();
|
|
746
|
-
return { status: 200, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: ctx.installScript };
|
|
747
|
-
}
|
|
748
743
|
|
|
749
744
|
// WebFinger: acct:<handle>@<host> → the fronted actor. This is what makes
|
|
750
745
|
// @name@fedipod.net a real handle the fediverse can look up.
|
|
@@ -804,23 +799,47 @@ async function route(request, ctx) {
|
|
|
804
799
|
const CLOSED = 'this address is closed';
|
|
805
800
|
|
|
806
801
|
// Inbox: verify at the door, forward to the user's pod inbox. This is the
|
|
807
|
-
// gateway, per user.
|
|
802
|
+
// gateway, per user. A forum's own page posts here from its own host, so the
|
|
803
|
+
// door answers a browser's preflight and carries the open origin; a delivery
|
|
804
|
+
// needs no credential, and a browser sends none across origins.
|
|
808
805
|
if (up.rest === 'ap/inbox/' || up.rest === 'ap/inbox') {
|
|
806
|
+
const doorCors = {
|
|
807
|
+
'access-control-allow-origin': '*',
|
|
808
|
+
'access-control-allow-methods': 'POST, OPTIONS',
|
|
809
|
+
'access-control-allow-headers': 'Content-Type',
|
|
810
|
+
'access-control-max-age': '86400',
|
|
811
|
+
};
|
|
812
|
+
if (request.method === 'OPTIONS') return { status: 204, headers: { ...doorCors, allow: 'POST, OPTIONS' }, body: null };
|
|
809
813
|
if (request.method !== 'POST') return { status: 405, headers: {}, body: '' };
|
|
810
|
-
if (movedBase) { console.log(`door @${up.handle}: delivery → 410 (moved)`); return gone(); }
|
|
811
|
-
if (closed) { console.log(`door @${up.handle}: delivery → 410 (closed)`); return gone(
|
|
814
|
+
if (movedBase) { console.log(`door @${up.handle}: delivery → 410 (moved)`); return gone(doorCors); }
|
|
815
|
+
if (closed) { console.log(`door @${up.handle}: delivery → 410 (closed)`); return gone(doorCors, CLOSED); }
|
|
812
816
|
const policy = await policyFor(rec, ctx.fetchImpl || fetch);
|
|
813
817
|
const standing = await accountState(ctx, up.handle, rec);
|
|
814
818
|
// A browser account's mail waits here while its app is closed (held-mail.mjs).
|
|
815
819
|
const held = holdsMail(ctx, rec) && !(await isPresent(ctx, up.handle));
|
|
816
|
-
const { status, reason, content, bytes, type, notifies } = await handleDelivery(request, identFor(rec, policy),
|
|
820
|
+
const { status, reason, content, bytes, type, notifies, name, raw, receipt } = await handleDelivery(request, identFor(rec, policy),
|
|
817
821
|
{ podPut: held ? holdingPut(ctx, up.handle, rec) : (u, b, ct) => ctx.podPut(up.handle, u, b, ct),
|
|
818
822
|
fetchImpl: ctx.fetchImpl, paused: standing.paused });
|
|
819
823
|
// One line per delivery, so "did it arrive at the door" has an answer.
|
|
820
824
|
console.log(`door @${up.handle}: delivery → ${status} (${reason}${held && status === 202 ? ', held' : ''})`);
|
|
821
825
|
if (status === 202 && content) await noteReceived(ctx, up.handle, rec, bytes);
|
|
822
826
|
if (held && status === 202) await afterHeld(ctx, up.handle, rec, { type, notifies });
|
|
823
|
-
|
|
827
|
+
// A forum fedipod.net keeps: the delivery is placed now, in this request,
|
|
828
|
+
// and the run that carries it is started. Whatever could not be placed
|
|
829
|
+
// here is still in the inbox for that run.
|
|
830
|
+
if (status === 202 && name && ctx.forum?.isForumRow(rec) && rec.keeper) {
|
|
831
|
+
const placed = await ctx.forum.placeAtDoor(ctx, up.handle, rec, { name, raw, receipt })
|
|
832
|
+
.catch((e) => ({ placed: false, why: e.message }));
|
|
833
|
+
console.log(`door @${up.handle}: ${placed.placed ? 'placed' : `not placed (${placed.why})`}`);
|
|
834
|
+
// Placed means written on the pod: the edge's copies of the category's
|
|
835
|
+
// and the forum's documents go, so the page that posted reads it back.
|
|
836
|
+
if (placed.placed) {
|
|
837
|
+
const tags = [`u-${up.handle}`, ...(placed.forum && placed.forum !== up.handle ? [`u-${placed.forum}`] : [])];
|
|
838
|
+
await ctx.purge?.(tags).catch((e) => console.log(`purge @${up.handle}: ${e?.message || e}`));
|
|
839
|
+
}
|
|
840
|
+
if (placed.forum && ctx.startKeeper) await ctx.startKeeper(placed.forum).catch((e) => console.log(`keeper @${placed.forum}: not started (${e.message})`));
|
|
841
|
+
}
|
|
842
|
+
return { status, headers: doorCors, body: '' };
|
|
824
843
|
}
|
|
825
844
|
|
|
826
845
|
// Outbox: the owner's own post, from any client that speaks ActivityPub
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// front-pages.mjs — the front's pages and the files they load, read from
|
|
2
2
|
// beside this package: the opt-in page, the roster, the notices page, the
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// feeds answers 404.
|
|
3
|
+
// root page, each page's own script and the sign-in library. A file that is
|
|
4
|
+
// not there comes back null, and the route it feeds answers 404.
|
|
6
5
|
|
|
7
6
|
import fs from 'node:fs';
|
|
8
7
|
import path from 'node:path';
|
|
@@ -26,6 +25,5 @@ export function frontPages() {
|
|
|
26
25
|
noticesPage: readOrNull('notices.html'),
|
|
27
26
|
authBundle: readOrNull('solid-oidc-client.js'),
|
|
28
27
|
pageScripts,
|
|
29
|
-
installScript: readOrNull('install.sh'),
|
|
30
28
|
};
|
|
31
29
|
}
|
|
@@ -138,7 +138,7 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
|
|
|
138
138
|
if (!okA) return { status: 502, reason: 'pod inbox write failed' };
|
|
139
139
|
if (receipt) await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
|
|
140
140
|
return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified', content, bytes: Buffer.byteLength(raw),
|
|
141
|
-
type: activity.type, notifies: notifies(activity, ident) };
|
|
141
|
+
type: activity.type, notifies: notifies(activity, ident), name: hash, raw, receipt };
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
// The outbox door: the owner's own post, taken on their behalf.
|
|
@@ -166,8 +166,18 @@ export async function routeKeeperApi(request, pathname, ctx, deps) {
|
|
|
166
166
|
const next = { ...rec };
|
|
167
167
|
if (on) next.keeper = { webId: ctx.keeperWebId, at: new Date().toISOString() };
|
|
168
168
|
else delete next.keeper;
|
|
169
|
+
// A forum's category names the forum it belongs to, so a delivery to the
|
|
170
|
+
// category finds the forum's copy and lease.
|
|
171
|
+
if (typeof body.forum === 'string' && /^[a-z0-9][a-z0-9._-]{0,60}$/u.test(body.forum)) next.forum = body.forum;
|
|
169
172
|
await ctx.putDirectory(handle, next);
|
|
170
173
|
console.log(`keeper @${handle}: ${on ? 'on' : 'off'}, at the owner's word`);
|
|
174
|
+
// A forum's own row turned on starts its first run now: the run makes the
|
|
175
|
+
// copies, mints the keys a new forum has none of, and publishes its actors,
|
|
176
|
+
// so there is a forum to read before anyone posts to it. Its categories'
|
|
177
|
+
// rows are turned on before it (setup.mjs in the forum package).
|
|
178
|
+
if (on && next.kind === 'application' && ctx.forum?.isForumRow(next) && ctx.startKeeper) {
|
|
179
|
+
await ctx.startKeeper(handle).catch((e) => console.log(`keeper @${handle}: not started (${e.message})`));
|
|
180
|
+
}
|
|
171
181
|
return j(200, { ok: true, handle, kept: on, keeper: ctx.keeperWebId || null });
|
|
172
182
|
}
|
|
173
183
|
|
package/lib/gateway/keeper.mjs
CHANGED
|
@@ -33,6 +33,9 @@ export { keeperSession };
|
|
|
33
33
|
* drain reads it, as before.
|
|
34
34
|
*/
|
|
35
35
|
export async function keepOnce(ctx, handle, rec, { log = console.log, session = null } = {}) {
|
|
36
|
+
// A forum, or one of its categories, is run whole by the forum's own code
|
|
37
|
+
// (ctx.forum: the forum package's gateway module), not by an account's.
|
|
38
|
+
if (ctx.forum?.isForumRow(rec)) return ctx.forum.keepOnce(ctx, handle, rec, { log, session });
|
|
36
39
|
const say = (m) => log(`keeper @${handle}: ${m}`);
|
|
37
40
|
const at = await reachAccount(ctx, handle, rec, { log, session });
|
|
38
41
|
if (at.skipped) return { skipped: at.skipped };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.44.0",
|
|
4
4
|
"description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -51,11 +51,11 @@
|
|
|
51
51
|
"gateway.md"
|
|
52
52
|
],
|
|
53
53
|
"scripts": {
|
|
54
|
-
"start": "node
|
|
54
|
+
"start": "node scripts/start.mjs",
|
|
55
55
|
"install-service": "node bin/fedipod.mjs install-service",
|
|
56
56
|
"uninstall-service": "node bin/fedipod.mjs uninstall-service",
|
|
57
57
|
"prepublishOnly": "npm test",
|
|
58
|
-
"test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test packages/fedipod-bb/test/*.test.mjs && node scripts/check-sizes.mjs",
|
|
58
|
+
"test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node packages/fedipod-bb/scripts/link-fedipod.mjs && node --test packages/fedipod-bb/test/*.test.mjs claude/smoke-tests/forum-signup-form.test.mjs && node scripts/check-sizes.mjs",
|
|
59
59
|
"verify-ui": "node scripts/verify-ui.mjs",
|
|
60
60
|
"build:sw": "node scripts/build-app.mjs --entry web/app/sw-src.mjs --out web/app/dist/sw.js",
|
|
61
61
|
"build:boot": "node scripts/build-app.mjs --entry web/app/boot.mjs --out web/app/dist/boot.js",
|
package/run-agent.mjs
CHANGED
|
@@ -52,22 +52,17 @@ export async function startAgent({
|
|
|
52
52
|
handle = null,
|
|
53
53
|
} = {}) {
|
|
54
54
|
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
55
|
-
// A home can hold a FORUM rather than a person or a group
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// The forum package is beside this file in a checkout and absent from an
|
|
60
|
-
// installed fedipod; a home that holds a forum needs it, a person's does not.
|
|
61
|
-
let forum = null;
|
|
55
|
+
// A home can hold a FORUM rather than a person or a group; its credential
|
|
56
|
+
// says so. A forum is run by its own package (`fedipod-bb start`), and
|
|
57
|
+
// running this agent over its home would publish a person where a forum
|
|
58
|
+
// lives.
|
|
62
59
|
try {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return forum.runForum({ home, port, log: (...a) => console.log(`[bb:${port}]`, ...a) });
|
|
70
|
-
}
|
|
60
|
+
const cred = JSON.parse(fs.readFileSync(path.join(home, 'credential.json'), 'utf8'));
|
|
61
|
+
if (String(cred.root || '').replace(/\/$/u, '') === 'fedipod-bb') {
|
|
62
|
+
console.error(`${home} holds a forum — run it with: fedipod-bb start --home ${home}`);
|
|
63
|
+
process.exit(2);
|
|
64
|
+
}
|
|
65
|
+
} catch { /* no credential yet, or not a forum */ }
|
|
71
66
|
// connect() sets this from pod state a moment later, but the browser may
|
|
72
67
|
// already be opening — seed it from what setup recorded so the named origin
|
|
73
68
|
// works from the first request.
|
|
@@ -18,6 +18,10 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fn-bundles-'));
|
|
|
18
18
|
let fails = 0;
|
|
19
19
|
const check = (ok, msg) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${msg}`); if (!ok) fails++; };
|
|
20
20
|
try {
|
|
21
|
+
// The forum the front names (netlify/forum-support.mjs) imports fedipod by
|
|
22
|
+
// name; in this checkout that must be the checkout itself, or the bundle
|
|
23
|
+
// carries a registry copy beside the Gateway's own code.
|
|
24
|
+
execFileSync(process.execPath, [path.join(root, 'packages/fedipod-bb/scripts/link-fedipod.mjs')], { cwd: root, stdio: 'inherit' });
|
|
21
25
|
execFileSync('netlify', ['functions:build', '--src', path.join(root, 'netlify/functions'), '--functions', path.join(tmp, 'zips')],
|
|
22
26
|
{ cwd: root, stdio: 'ignore' });
|
|
23
27
|
const manifest = JSON.parse(fs.readFileSync(path.join(tmp, 'zips/manifest.json'), 'utf8'));
|
|
@@ -326,7 +326,7 @@ if (!failures) pass('every in-scope caller reaches the pod through a named opera
|
|
|
326
326
|
try {
|
|
327
327
|
const { frontPages } = await import('fedipod/front-pages');
|
|
328
328
|
const pages = frontPages();
|
|
329
|
-
const missing = ['signupPage', 'runPage', 'adminPage', 'noticesPage', 'authBundle'
|
|
329
|
+
const missing = ['signupPage', 'runPage', 'adminPage', 'noticesPage', 'authBundle'].filter((k) => !pages[k])
|
|
330
330
|
.concat(Object.entries(pages.pageScripts).filter(([, v]) => !v).map(([k]) => k));
|
|
331
331
|
if (missing.length) { fail(`frontPages() is missing ${missing.join(', ')}`); bad++; }
|
|
332
332
|
} catch (e) { fail(`frontPages(): ${e.message}`); bad++; }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// start.mjs — what `npm start` says in this repository. Nothing starts here:
|
|
2
|
+
// FediPod runs in a browser at a Gateway, and a Gateway is deployed, not
|
|
3
|
+
// started. Until 2026-09-28 `npm start` started the DeviceAgent, which is
|
|
4
|
+
// deprecated and opened a setup page asking for a pod password.
|
|
5
|
+
console.log(`FediPod is not started from this directory.
|
|
6
|
+
|
|
7
|
+
In a browser: open a Gateway such as https://fedipod.net and create an
|
|
8
|
+
account. Nothing is installed.
|
|
9
|
+
A Gateway of your own: deploy this repository to Netlify — netlify/README.md
|
|
10
|
+
A pod server with FediPod built in: packages/fedipod-server/README.md
|
|
11
|
+
A forum: packages/fedipod-bb/README.md, or "A forum" on a
|
|
12
|
+
Gateway's sign-up page
|
|
13
|
+
`);
|
package/web/app/boot.mjs
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
// Returning: fedipodSignin({ address }) — redirect to the pod's login.
|
|
16
16
|
// On every load: fedipodOnLoad() — finish a redirect, or restore, then boot.
|
|
17
17
|
import { signUp, moveIn, readAccount, handleProblem } from './signup.mjs';
|
|
18
|
+
import { signUpForum, forumsOffered } from './signup-forum.mjs';
|
|
19
|
+
import { categoriesFrom, moderatorFrom } from './forum-form.mjs';
|
|
18
20
|
import { podRootPath, chosenRoot } from '../../lib/core/place.mjs';
|
|
19
21
|
import * as podActor from '../../lib/pod/actor.mjs';
|
|
20
22
|
import * as podState from '../../lib/pod/state.mjs';
|
|
@@ -134,6 +136,14 @@ window.fedipodSignup = async ({ onStep, ...answers }) => {
|
|
|
134
136
|
await signUp(answers, { session, onStep, frontOrigin: location.origin });
|
|
135
137
|
await bootWorker({ reset: true });
|
|
136
138
|
};
|
|
139
|
+
// A forum, on the pod the person just made for it (signup-forum.mjs): written
|
|
140
|
+
// on the pod, its addresses taken here, this site named its keeper. No agent
|
|
141
|
+
// boots here; the forum runs at this site from its first run on.
|
|
142
|
+
window.fedipodForumSignup = async ({ onStep, ...answers }) => {
|
|
143
|
+
const session = await getSession();
|
|
144
|
+
if (!session) throw new Error("Sign in at the forum's pod first.");
|
|
145
|
+
return signUpForum(answers, { session, onStep, frontOrigin: location.origin });
|
|
146
|
+
};
|
|
137
147
|
// An account whose address lives at another gateway, moving here: the pod's
|
|
138
148
|
// part (signup.mjs moveIn), then a boot under the new ids, which finishes
|
|
139
149
|
// the move by telling the old gateway and the followers (gateway-move.mjs).
|
|
@@ -462,6 +472,9 @@ if (typeof document !== 'undefined') (async () => {
|
|
|
462
472
|
$('container').placeholder = podRootPath(signedPod);
|
|
463
473
|
$('container-field').hidden = !!moveFrom;
|
|
464
474
|
$('movein-note').hidden = !moveFrom;
|
|
475
|
+
// What was being made was chosen before the pod's login; the page came
|
|
476
|
+
// back fresh from it.
|
|
477
|
+
try { const m = sessionStorage.getItem('fedipod:making'); if (m) for (const r of f().making) r.checked = r.value === m; } catch { /* storage blocked */ }
|
|
465
478
|
if (moveFrom) {
|
|
466
479
|
$('movein-from').textContent = moveFrom.address;
|
|
467
480
|
if (!f().handle.value) f().handle.value = moveFrom.config.handle;
|
|
@@ -499,7 +512,10 @@ if (typeof document !== 'undefined') (async () => {
|
|
|
499
512
|
// from the signed-in pod, not guessed from the provider.
|
|
500
513
|
const pathPod = () => { try { return new URL(signedPod).pathname !== '/'; } catch { return false; } };
|
|
501
514
|
// A move between gateways is an address at a gateway: no choice either.
|
|
502
|
-
|
|
515
|
+
// What is being made: a person's account, or a forum (offered only where
|
|
516
|
+
// this site hosts forums). A forum's address is always at this site.
|
|
517
|
+
const making = () => (forumsOffered() && f().making?.value === 'forum' ? 'forum' : 'account');
|
|
518
|
+
const shape = () => ((pathPod() || moveFrom || making() === 'forum') ? 'front' : f().shape.value);
|
|
503
519
|
const answers = () => ({ handle: f().handle.value.trim().toLowerCase(), shape: shape(), container: f().container.value.trim() });
|
|
504
520
|
// Where the data will be, in full, as the person types.
|
|
505
521
|
const previewPlace = () => {
|
|
@@ -516,10 +532,30 @@ if (typeof document !== 'undefined') (async () => {
|
|
|
516
532
|
// The shape choice is fixed for a path pod, and open for a host-root pod.
|
|
517
533
|
// A path pod has no choice to make: the radios go away and the note says why.
|
|
518
534
|
const applyShape = () => {
|
|
519
|
-
const fixed = pathPod() || !!moveFrom;
|
|
535
|
+
const fixed = pathPod() || !!moveFrom || making() === 'forum';
|
|
520
536
|
for (const r of f().shape) { if (fixed) r.checked = r.value === 'front'; }
|
|
521
537
|
$('shape-group').hidden = fixed;
|
|
522
|
-
$('shape-hint').hidden = !fixed || !!moveFrom; // a move
|
|
538
|
+
$('shape-hint').hidden = !fixed || !!moveFrom || making() === 'forum'; // a move and a forum explain themselves
|
|
539
|
+
};
|
|
540
|
+
// The forum's fields show for a forum; the person's container and address
|
|
541
|
+
// choices go, and the words say what is being made.
|
|
542
|
+
const applyMaking = () => {
|
|
543
|
+
const forum = making() === 'forum';
|
|
544
|
+
$('making-hint').hidden = !forum;
|
|
545
|
+
$('forum-fields').hidden = !forum;
|
|
546
|
+
$('container-field').hidden = forum || !!moveFrom;
|
|
547
|
+
$('identity-legend').textContent = forum ? 'Your forum' : 'Your Fediverse Identity';
|
|
548
|
+
$('handle-label').textContent = forum ? 'Forum handle' : 'Fediverse handle';
|
|
549
|
+
$('handle-hint').textContent = forum
|
|
550
|
+
? `The name in the forum's address, @handle@${location.host}. This is permanent. Letters, digits, and hyphens only.`
|
|
551
|
+
: 'The name in your Fediverse address. This is permanent. Letters, digits, and hyphens only.';
|
|
552
|
+
$('handle').placeholder = forum ? "the handle part of the forum's address: @HANDLE@server" : 'the handle part of your address: @HANDLE@server';
|
|
553
|
+
$('signed-pod-label').textContent = forum ? "The forum's pod" : 'Your pod';
|
|
554
|
+
$('addr-label').textContent = forum ? "The forum's address" : 'Your Fediverse address';
|
|
555
|
+
$('after-hint').textContent = forum
|
|
556
|
+
? "The handle, the categories' short names and the pod cannot be changed afterwards. Everything else you set on the forum's settings page."
|
|
557
|
+
: 'The handle and the pod cannot be changed afterwards. Everything else — display name, bio, pictures — you set later in the client.';
|
|
558
|
+
$('submit').textContent = forum ? 'Create the forum' : moveFrom ? 'Move your account here' : 'Create your Fediverse account';
|
|
523
559
|
};
|
|
524
560
|
const applyMode = () => {
|
|
525
561
|
const existing = f().mode.value === 'existing';
|
|
@@ -529,8 +565,10 @@ if (typeof document !== 'undefined') (async () => {
|
|
|
529
565
|
$('register-link').href = url || '#';
|
|
530
566
|
$('register-link').textContent = providerHost() ? `Create your pod at ${providerHost()}` : 'Create your pod at your provider';
|
|
531
567
|
};
|
|
532
|
-
for (const el of $('form').elements) for (const evt of ['input', 'change']) el.addEventListener(evt, () => { applyMode(); applyShape(); previewAddr(); previewPlace(); });
|
|
568
|
+
for (const el of $('form').elements) for (const evt of ['input', 'change']) el.addEventListener(evt, () => { applyMode(); applyMaking(); applyShape(); previewAddr(); previewPlace(); });
|
|
533
569
|
applyMode();
|
|
570
|
+
$('making-group').hidden = !forumsOffered();
|
|
571
|
+
applyMaking();
|
|
534
572
|
|
|
535
573
|
// Step machine: one screen at a time, each gated by its own validation.
|
|
536
574
|
const STEP_IDS = ['step-1', 'step-2'];
|
|
@@ -538,7 +576,7 @@ if (typeof document !== 'undefined') (async () => {
|
|
|
538
576
|
const goStep = (n) => {
|
|
539
577
|
STEP_IDS.forEach((id, i) => { $(id).hidden = i !== n - 1; });
|
|
540
578
|
$('err-1').textContent = ''; $('form-error').textContent = '';
|
|
541
|
-
if (n === 2) { applyShape(); previewAddr(); previewPlace(); $('index-ask').hidden = true; }
|
|
579
|
+
if (n === 2) { applyMaking(); applyShape(); previewAddr(); previewPlace(); $('index-ask').hidden = true; }
|
|
542
580
|
if (FOCUS[n]) $(FOCUS[n]).focus();
|
|
543
581
|
};
|
|
544
582
|
const validateStep1 = () => {
|
|
@@ -547,6 +585,12 @@ if (typeof document !== 'undefined') (async () => {
|
|
|
547
585
|
};
|
|
548
586
|
const validateStep2 = () => {
|
|
549
587
|
const hp = window.fedipodHandleProblem(f().handle.value.trim().toLowerCase());
|
|
588
|
+
if (making() === 'forum') {
|
|
589
|
+
if (hp) return `Forum handle: ${hp}`;
|
|
590
|
+
if (!f().forumName.value.trim()) return 'Forum name: a name is required.';
|
|
591
|
+
try { categoriesFrom(f().categories.value); moderatorFrom(f().moderator.value, location.origin); } catch (e) { return e.message; }
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
550
594
|
if (hp) return `Fediverse handle: ${hp}`;
|
|
551
595
|
if (!moveFrom) {
|
|
552
596
|
const where = chosenRoot(signedPod, f().container.value).problem;
|
|
@@ -561,6 +605,7 @@ if (typeof document !== 'undefined') (async () => {
|
|
|
561
605
|
$('to-2').addEventListener('click', async () => {
|
|
562
606
|
const e = validateStep1(); if (e) { $('err-1').textContent = e; return; }
|
|
563
607
|
$('err-1').textContent = ''; $('to-2').disabled = true;
|
|
608
|
+
try { sessionStorage.setItem('fedipod:making', making()); } catch { /* storage blocked */ }
|
|
564
609
|
try { await window.fedipodPodLogin({ issuer: providerUrl() }); }
|
|
565
610
|
catch (err) { $('err-1').textContent = err.message || String(err); $('to-2').disabled = false; }
|
|
566
611
|
});
|
|
@@ -578,15 +623,28 @@ if (typeof document !== 'undefined') (async () => {
|
|
|
578
623
|
const LABELS = { pod: 'Checking your pod', keys: 'Making your signing key', gateway: 'Connecting your mail door',
|
|
579
624
|
place: 'Recording where your account lives' };
|
|
580
625
|
const MOVE_LABELS = { pod: 'Reading your account on your pod', gateway: 'Taking your address here', keys: 'Moving your key and account record' };
|
|
626
|
+
const FORUM_LABELS = { pod: "Checking the forum's pod", write: 'Writing the forum on its pod', attach: 'Taking its addresses here', keep: 'Making this site its keeper' };
|
|
581
627
|
// Run the setup. `createIndex` is the person's yes to a new public type index.
|
|
582
628
|
const run = async (createIndex = false) => {
|
|
583
629
|
const a = { ...answers(), ...(createIndex ? { createIndex: true } : {}) };
|
|
584
630
|
$('pane-form').hidden = true; $('running').hidden = false;
|
|
585
|
-
$('running-title').textContent = 'Setting up…'; $('run-error').textContent = ''; $('run-actions').hidden = true;
|
|
631
|
+
$('running-title').textContent = 'Setting up…'; $('run-error').textContent = ''; $('run-actions').hidden = true; $('run-done').hidden = true;
|
|
586
632
|
const steps = $('steps'); steps.textContent = ''; const mark = {};
|
|
587
|
-
const
|
|
633
|
+
const forum = making() === 'forum';
|
|
634
|
+
const labels = forum ? FORUM_LABELS : moveFrom ? MOVE_LABELS : LABELS;
|
|
588
635
|
const onStep = (k, st) => { if (!mark[k]) { const li = document.createElement('li'); steps.appendChild(li); mark[k] = li; } mark[k].textContent = (st === 'ok' ? '✓ ' : st === 'running' ? '… ' : '') + (labels[k] || k); };
|
|
589
636
|
try {
|
|
637
|
+
if (forum) {
|
|
638
|
+
const made = await window.fedipodForumSignup({ handle: a.handle, name: f().forumName.value, categories: f().categories.value,
|
|
639
|
+
moderator: f().moderator.value, onStep });
|
|
640
|
+
try { sessionStorage.removeItem('fedipod:making'); } catch { /* storage blocked */ }
|
|
641
|
+
// The forum's page, read through this site; its first run publishes
|
|
642
|
+
// the forum within the minute.
|
|
643
|
+
$('running-title').textContent = `Your forum is ready: ${made.address}`;
|
|
644
|
+
$('run-done-link').href = `${location.origin}/bb/?forum=${encodeURIComponent(made.handle)}`;
|
|
645
|
+
$('run-done').hidden = false;
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
590
648
|
if (moveFrom) await window.fedipodMoveIn({ handle: a.handle, onStep });
|
|
591
649
|
else await window.fedipodSignup({ ...a, onStep });
|
|
592
650
|
location.href = '/admin/client/';
|