fedipod 1.43.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 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
 
@@ -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,11 +799,20 @@ 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({}, CLOSED); }
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).
@@ -827,9 +831,15 @@ async function route(request, ctx) {
827
831
  const placed = await ctx.forum.placeAtDoor(ctx, up.handle, rec, { name, raw, receipt })
828
832
  .catch((e) => ({ placed: false, why: e.message }));
829
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
+ }
830
840
  if (placed.forum && ctx.startKeeper) await ctx.startKeeper(placed.forum).catch((e) => console.log(`keeper @${placed.forum}: not started (${e.message})`));
831
841
  }
832
- return { status, headers: {}, body: '' };
842
+ return { status, headers: doorCors, body: '' };
833
843
  }
834
844
 
835
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
- // new-account page, each page's own script, the sign-in library and the
4
- // installer. A file that is not there comes back null, and the route it
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
  }
@@ -171,6 +171,13 @@ export async function routeKeeperApi(request, pathname, ctx, deps) {
171
171
  if (typeof body.forum === 'string' && /^[a-z0-9][a-z0-9._-]{0,60}$/u.test(body.forum)) next.forum = body.forum;
172
172
  await ctx.putDirectory(handle, next);
173
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
+ }
174
181
  return j(200, { ok: true, handle, kept: on, keeper: ctx.keeperWebId || null });
175
182
  }
176
183
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "1.43.0",
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 bin/fedipod.mjs up",
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 packages/fedipod-bb/scripts/link-fedipod.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",
@@ -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', 'installScript'].filter((k) => !pages[k])
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
- const shape = () => ((pathPod() || moveFrom) ? 'front' : f().shape.value);
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 explains itself in its own note
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 labels = moveFrom ? MOVE_LABELS : LABELS;
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/';