fedipod 0.9.0 → 0.11.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/bin/fedipod.mjs CHANGED
@@ -1492,12 +1492,44 @@ if (cmd === 'up') {
1492
1492
  console.log('UI password set — /oauth/authorize now shows a login form (restart a running agent to pick it up)');
1493
1493
  } else if (cmd === 'gateway' || cmd === 'front') {
1494
1494
  // Attach this identity to a gateway ('front' kept as an alias). Shapes:
1495
- // fedipod gateway <.../ap/inbox/> --secret S --inbox-only the usual one:
1495
+ // fedipod gateway --attach <origin> [--name N] [--fronted] ask the gateway
1496
+ // itself: the agent proves the pod with its own credential and stores
1497
+ // the door + secret the gateway answers with.
1498
+ // fedipod gateway <.../ap/inbox/> --secret S --inbox-only paste-in form:
1496
1499
  // identity stays on the pod; only the advertised inbox moves to the
1497
1500
  // gateway's door. Leaving is one republish with the pod inbox.
1498
1501
  // fedipod gateway <.../ap/actor> --secret S fronted identity
1499
1502
  // fedipod gateway --detach back to the pod inbox
1500
1503
  requireIdentity();
1504
+ if (has('attach')) {
1505
+ const front = flag('attach');
1506
+ if (!/^https?:\/\/\S+$/.test(String(front || ''))) {
1507
+ console.error('usage: fedipod gateway --attach <https://gateway-origin> [--name yourname] [--fronted]');
1508
+ process.exit(2);
1509
+ }
1510
+ try {
1511
+ const res = await localFetch(HOME, PORT, `/gateway`, {
1512
+ method: 'POST', headers: { 'content-type': 'application/json' },
1513
+ body: JSON.stringify({ action: 'attach', front,
1514
+ ...(flag('name') ? { handle: flag('name') } : {}),
1515
+ ...(has('fronted') ? { fronted: true } : {}) }),
1516
+ });
1517
+ const body = await res.json();
1518
+ if (res.status >= 400) { console.error(body.error || `HTTP ${res.status}`); process.exit(1); }
1519
+ if (body.frontActor) {
1520
+ console.log(`attached — this identity now publishes as ${body.address || body.frontActor}`);
1521
+ console.log('The agent is restarting itself to publish under the front.');
1522
+ } else {
1523
+ console.log(`attached — your mail now arrives through ${body.url}, filtered; your name has not moved.`);
1524
+ console.log('Starting in shadow: the door filters, and the agent measures how much');
1525
+ console.log('verifies before it believes any receipt. Move to trust when you are ready.');
1526
+ }
1527
+ } catch (e) {
1528
+ console.error(`agent not reachable on :${PORT} (${e.message}) — start it, then attach`);
1529
+ process.exit(1);
1530
+ }
1531
+ process.exit(0);
1532
+ }
1501
1533
  if (has('detach')) {
1502
1534
  try {
1503
1535
  const res = await localFetch(HOME, PORT, `/gateway`, {
package/cli.md CHANGED
@@ -38,9 +38,10 @@ several identities, say which you mean. `profiles` does not list identities
38
38
  under a custom `AP_HOME`.
39
39
 
40
40
  Commands that manage a live identity — `alias`, `admit`, `import`, `archive`,
41
- `bsky`, `describe`, `status`, `rebuild`, `gateway --detach` and the group
42
- commands — talk to the running agent and say so when it is not there. `keys`,
43
- `state`, `home` and attaching a gateway want the agent stopped.
41
+ `bsky`, `describe`, `status`, `rebuild`, `gateway --attach`,
42
+ `gateway --detach` and the group commands — talk to the running agent and say
43
+ so when it is not there. `keys`, `state`, `home` and the paste-in gateway form
44
+ (`gateway <url> --secret …`) want the agent stopped.
44
45
 
45
46
  ## Recovery
46
47
 
@@ -189,9 +190,16 @@ are not imported.
189
190
  ## The gateway
190
191
 
191
192
  ```
193
+ fedipod gateway --attach <gateway-origin> [--name N] [--fronted]
192
194
  fedipod gateway <door-inbox-url> --secret <hmac> --inbox-only
193
195
  fedipod gateway --detach
194
196
  ```
197
+
198
+ `--attach` asks the gateway itself: the agent proves the pod with its own
199
+ credential, and the gateway answers with the door and the receipt secret.
200
+ `--name` defaults to your handle; `--fronted` takes a gateway-based name,
201
+ and the agent restarts itself to publish under it. The admin page's Gateway
202
+ row offers the same action with the name checked as you type.
195
203
  Attaching points your actor's advertised inbox at a gateway's door, so
196
204
  deliveries are verified and de-junked before they reach your pod; your name,
197
205
  key and data stay on your pod. The URL and secret come from the gateway's
package/gui.md CHANGED
@@ -3,6 +3,8 @@
3
3
  Open `https://localhost:8030/` while any agent is running — it forwards you to the agent — then choose `manage account` and select the actor you want from the local actors dropdown.
4
4
  Picking an actor marked "(stopped)" starts its agent, then opens its page.
5
5
 
6
+ The software row names the version the agent is running. When the copy on the machine is further ahead — after an update, or after pulling a checkout — it says so and asks for a restart, because an agent goes on serving the code it started with until it is restarted.
7
+
6
8
  `Update`, shown on the software row when a newer FediPod exists, pulls the latest version and restarts every agent on the machine — agents installed as services; any started by hand need their own restart. The same row flags an older data layout after an update; the `fedipod upgrade` terminal command lists those moves and the commands that make them.
7
9
 
8
10
  * **Parking** (the status control) stops the mail and unfollows people you follow, but keeps your handle
@@ -70,12 +72,28 @@ follows the group's own moderation settings; see [Groups](groups.md).
70
72
  **new followers** control switches between waiting for your approval and
71
73
  accepting automatically.
72
74
 
75
+ ## The gateway
76
+
77
+ The **gateway** row on the record (under **software**) attaches this account
78
+ to a mail-filtering gateway (a fedipod.net-style front) and detaches it; when
79
+ attached it names the gateway, and the handle there when you took one. Both
80
+ buttons open a popup. Attaching offers the choice of handle: keep your
81
+ pod-based handle (`@you@your.pod` — you can drop the gateway any time and
82
+ keep everything), or create a handle at the gateway (`@you@the-gateway` —
83
+ your address lives on the gateway's domain, but not your data or key), typed
84
+ straight into the blank of the address with a free/taken check as you type.
85
+ The gateway account is created automatically with either choice, and the
86
+ agent proves the pod with its own credential, so no password is typed
87
+ anywhere. Taking or leaving a gateway handle restarts the agent by itself;
88
+ detaching republishes your actor with your pod's own inbox.
89
+
73
90
  ## Moving here from another server
74
91
 
75
92
  Open **Transfer an account here** on the action rail and add your old account
76
93
  as an alias, set
77
94
  **new followers** to *accepted automatically*, then trigger the move on the
78
- old server; your followers arrive by themselves. Removing an alias asks
95
+ old server (on Mastodon: Preferences Account *Move to a different
96
+ account*); your followers arrive by themselves. Removing an alias asks
79
97
  twice — servers still processing the move check it while they retry. The CSV
80
98
  files from the old server's export are imported with the CLI; see
81
99
  [CLI admin](cli.md).
package/lib/admin.mjs CHANGED
@@ -16,7 +16,7 @@ import os from 'node:os';
16
16
  import { fileURLToPath, pathToFileURL } from 'node:url';
17
17
  import { followHandle, followActor, unfollowActor, ejectFollower, retractAnnouncement,
18
18
  admitRequest, refuseRequest, resolveHandle, applyModeration, announceModeration } from './social.mjs';
19
- import { addRemoveActivity, webfingerHost } from './wire.mjs';
19
+ import { addRemoveActivity, webfingerHost , publicHandle } from './wire.mjs';
20
20
  import { MastoApi, hashPassword } from './mastoapi.mjs';
21
21
  import { C2S } from './c2s.mjs';
22
22
  import { makeC2sAuth } from './oidc-auth.mjs';
@@ -32,6 +32,7 @@ import { portFree, freePortFrom } from './ports.mjs';
32
32
  import { claimDirectory, yieldDirectory } from './directory.mjs';
33
33
  import { localFetch } from './localapi.mjs';
34
34
  import { ensureTrustedTls } from './certs.mjs';
35
+ import { localVersion } from './update.mjs';
35
36
 
36
37
  const require = createRequire(import.meta.url);
37
38
  const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -325,7 +326,8 @@ function sendFile(res, baseDir, rel, auth) {
325
326
  // guards the operator's door (basePath) instead of the whole surface.
326
327
  export function buildAdminSurface({ agent, gate, allowed, log = console.log,
327
328
  port = null, handle = null, embedded = false, basePath = '/',
328
- publicOrigin = null, scheme = null }) {
329
+ publicOrigin = null, scheme = null,
330
+ versionOnDisk = () => localVersion(projectRoot) }) {
329
331
  const json = (res, status, obj) => sendJson(res, status, obj, allowed);
330
332
  const masto = new MastoApi({ agent, log, allowed, scheme, embedded });
331
333
  // The spec's own write API (§6), beside the facade. Its bearer fallback is
@@ -488,7 +490,10 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
488
490
  // the handle, and the pod host its actor URL sits on.
489
491
  let address = null;
490
492
  if (live?.handle && live?.actor) {
491
- try { address = `${live.handle}@${new URL(live.actor).host}`; } catch { /* not a URL yet */ }
493
+ try {
494
+ const front = live.actor.match(/\/u\/([^/]+)\/ap\/actor\/?$/)?.[1];
495
+ address = `${front || live.handle}@${new URL(live.actor).host}`;
496
+ } catch { /* not a URL yet */ }
492
497
  }
493
498
  return {
494
499
  name, port, current,
@@ -566,6 +571,11 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
566
571
  if (!cfg) return json(res, 409, { error: 'agent not configured — set it up at /admin/setup/' });
567
572
  const urls = agent.urls || agent.publisher?.urls || null;
568
573
  const wfHost = urls ? new URL(urls.base).host : null;
574
+ // A fronted identity's address is its name AT THE FRONT — the front
575
+ // serves the actor under that name, whatever this pod calls it.
576
+ const address = cfg.gateway?.frontActor
577
+ ? `@${publicHandle(cfg)}@${new URL(cfg.gateway.frontActor).host}`
578
+ : (wfHost ? `@${cfg.handle}@${wfHost}` : null);
569
579
  return json(res, 200, {
570
580
  // permanent
571
581
  handle: cfg.handle, remotePod: cfg.remotePod, issuer: cfg.issuer,
@@ -576,7 +586,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
576
586
  // same way every other id is — computed here rather than in the page,
577
587
  // which has no business knowing how they are made.
578
588
  accountId: urls?.actor ? agent.store.idFor(urls.actor) : null,
579
- address: wfHost ? `@${cfg.handle}@${wfHost}` : null,
589
+ address,
580
590
  // editable
581
591
  name: cfg.name || null, summary: cfg.summary || null, icon: cfg.icon || null,
582
592
  image: cfg.image || null, fields: cfg.fields || [],
@@ -592,6 +602,10 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
592
602
  privateRoot: agent.readCredential?.()?.privateRoot || null,
593
603
  mode: agent.status?.().mode || null, port, home: tildify(agent.home) || null,
594
604
  update: agent.updateInfo || null,
605
+ // What this process is running, against what a restart would run:
606
+ // the checkout can move while an agent stays up.
607
+ version: AGENT_VERSION,
608
+ versionOnDisk: versionOnDisk(),
595
609
  pendingUpgrade: agent.pendingUpgrade || [],
596
610
  // The connected Bluesky account, non-secret half. `connected` is the
597
611
  // credential's word, so a config entry orphaned by a deleted
@@ -1423,12 +1437,47 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1423
1437
  const cfg = { ...agent.store.getConfig() };
1424
1438
  const g = { ...(cfg.gateway || {}) };
1425
1439
  const inboxUrl = agent.publisher?.urls?.inbox;
1440
+ // Availability is a read: answered before the lease takeover below,
1441
+ // which every real gateway change does want.
1442
+ if (body.action === 'check') {
1443
+ const front = String(body.front || '').replace(/\/+$/, '');
1444
+ try { new URL(front); } catch { return json(res, 400, { error: 'front must be a gateway origin URL' }); }
1445
+ const chk = await fetch(`${front}/api/handle?handle=${encodeURIComponent(String(body.handle || '').toLowerCase())}`,
1446
+ { headers: { accept: 'application/json' } }).then((r) => r.json()).catch(() => null);
1447
+ if (!chk) return json(res, 502, { error: `${front} did not answer its handle check` });
1448
+ return json(res, 200, { available: !!chk.available, reason: chk.reason || null });
1449
+ }
1426
1450
  await agent.requestTakeover?.();
1427
1451
  const persist = async () => {
1428
1452
  cfg.gateway = g; agent.store.setConfig(cfg);
1429
1453
  if (agent.publisher) agent.publisher.config.gateway = g;
1430
1454
  await agent.store.flush();
1431
1455
  };
1456
+ // Fronting renames the actor's ids; the signing key is the same
1457
+ // identity's and moves with it, wherever the key record lives.
1458
+ const restampKeys = async (actorId) => {
1459
+ try {
1460
+ const kp = path.join(agent.home, 'keys.json');
1461
+ const rec = JSON.parse(fs.readFileSync(kp, 'utf8'));
1462
+ if (rec.mintedFor) { rec.mintedFor = actorId; fs.writeFileSync(kp, JSON.stringify(rec)); return; }
1463
+ } catch { /* not local — try pod state */ }
1464
+ const podRec = agent.store.read('keys.json', null);
1465
+ if (podRec?.mintedFor) { podRec.mintedFor = actorId; agent.store.write('keys.json', podRec); await agent.store.flush(); }
1466
+ };
1467
+ const podActorId = () => {
1468
+ const base = cfg.remotePod.endsWith('/') ? cfg.remotePod : `${cfg.remotePod}/`;
1469
+ const root = cfg.root ? (cfg.root.endsWith('/') ? cfg.root : `${cfg.root}/`) : 'activitypods-js/';
1470
+ return `${base}${root}ap/actor`;
1471
+ };
1472
+ // The reply first, the restart a beat later — same shape as /update.
1473
+ const selfRestart = (why) => setTimeout(() => {
1474
+ log(why);
1475
+ if (process.env.INVOCATION_ID) { process.exit(1); return; } // systemd: Restart=on-failure respawns
1476
+ const child = spawn(process.execPath, process.argv.slice(1),
1477
+ { detached: true, stdio: 'ignore', env: process.env });
1478
+ child.unref();
1479
+ setTimeout(() => process.exit(0), 300);
1480
+ }, 200);
1432
1481
  if (body.action === 'configure') {
1433
1482
  if (!/^https:\/\/\S+$/.test(String(body.url || ''))) return json(res, 400, { error: 'gateway url must be https' });
1434
1483
  if (!/^https?:\/\/\S+$/.test(String(body.webId || ''))) return json(res, 400, { error: 'gateway webId must be a URL' });
@@ -1478,16 +1527,91 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1478
1527
  else if (target !== 'off') await agent.publisher?.publishGatewayPolicy().catch(() => {});
1479
1528
  return json(res, 200, { ok: true, mode: g.mode });
1480
1529
  }
1530
+ // Attach through a multi-user front (fedipod.net and kin): the agent
1531
+ // proves the pod with its own credential — no browser, no password —
1532
+ // and the front answers with the door and the receipt secret.
1533
+ if (body.action === 'attach') {
1534
+ const front = String(body.front || '').replace(/\/+$/, '');
1535
+ let fu;
1536
+ try { fu = new URL(front); } catch { return json(res, 400, { error: 'front must be the gateway origin, like https://fedipod.net' }); }
1537
+ if (fu.protocol !== 'https:' && !/^(localhost|127\.0\.0\.1)$|\.localhost$/.test(fu.hostname)) {
1538
+ return json(res, 400, { error: 'front must be https' });
1539
+ }
1540
+ if (agent.embedded) {
1541
+ return json(res, 400, { error: 'this identity runs inside its pod server and has no portable credential — attach from a standalone agent' });
1542
+ }
1543
+ const named = !!String(body.handle || '').trim();
1544
+ let handle = String(body.handle || cfg.handle || '').toLowerCase().trim();
1545
+ if (!handle) return json(res, 400, { error: 'a name at the gateway is required' });
1546
+ const fronted = body.fronted === true;
1547
+ // Availability first, for a clean answer before anything is created.
1548
+ const avail = async (h) => fetch(`${front}/api/handle?handle=${encodeURIComponent(h)}`,
1549
+ { headers: { accept: 'application/json' } }).then((r) => r.json()).catch(() => null);
1550
+ let chk = await avail(handle);
1551
+ if (!chk) return json(res, 502, { error: `${front} did not answer its handle check` });
1552
+ // An unnamed door name is plumbing nobody reads: walk to a free
1553
+ // variant instead of failing over a label the user never chose.
1554
+ if (!chk.available && !named) {
1555
+ for (let i = 2; i <= 9 && !chk.available; i++) {
1556
+ const cand = `${handle}${i}`;
1557
+ const c = await avail(cand);
1558
+ if (c?.available) { handle = cand; chk = c; }
1559
+ }
1560
+ }
1561
+ if (!chk.available) return json(res, 409, { error: chk.reason || `the name ${handle} is taken at ${front}` });
1562
+ const frontActor = `${front}/u/${handle}/ap/actor`;
1563
+ if (g.frontActor && (!fronted || g.frontActor !== frontActor)) {
1564
+ return json(res, 400, { error: `this identity already fronts through ${g.frontActor} — changing a published front renames every id; detach first if you mean it` });
1565
+ }
1566
+ const attach = await agent.remote.session.fetch(`${front}/api/attach`, {
1567
+ method: 'POST', headers: { 'content-type': 'application/json' },
1568
+ body: JSON.stringify({ handle, podHome: agent.urls.home, kind: cfg.kind || 'person', fronted }),
1569
+ }).catch(() => null);
1570
+ if (!attach) return json(res, 502, { error: `${front} did not answer the attach` });
1571
+ const d = await attach.json().catch(() => ({}));
1572
+ if (attach.status !== 201) {
1573
+ return json(res, attach.status >= 400 && attach.status < 500 ? attach.status : 502,
1574
+ { error: d.error || `attach failed (HTTP ${attach.status})` });
1575
+ }
1576
+ g.url = String(d.doorInbox || `${front}/u/${handle}/ap/inbox/`);
1577
+ if (d.hmacSecret) g.hmacSecret = String(d.hmacSecret);
1578
+ if (fronted) g.frontActor = String(d.frontActor || frontActor);
1579
+ if (!g.mode || g.mode === 'off') g.mode = 'shadow';
1580
+ await persist();
1581
+ // Inbox-only applies live: the actor republishes advertising the
1582
+ // door. A front carries new ids, which are wired at startup — so a
1583
+ // fronted attach restarts this agent itself, the reply going out
1584
+ // first so it is not taken down with the process.
1585
+ if (!fronted) {
1586
+ await agent.publisher?.publishProfile();
1587
+ await agent.publisher?.publishGatewayPolicy?.().catch(() => {});
1588
+ return json(res, 200, { ok: true, mode: g.mode, url: g.url });
1589
+ }
1590
+ await restampKeys(g.frontActor);
1591
+ json(res, 200, { ok: true, mode: g.mode, url: g.url,
1592
+ frontActor: g.frontActor, address: d.address || null, restarting: true });
1593
+ selfRestart('restarting to publish under the front');
1594
+ return;
1595
+ }
1481
1596
  if (body.action === 'forget') {
1482
1597
  const wasLocked = g.mode === 'locked';
1598
+ const wasFronted = !!g.frontActor;
1483
1599
  delete cfg.gateway; agent.store.setConfig(cfg);
1484
1600
  if (agent.publisher) agent.publisher.config.gateway = undefined;
1485
1601
  await agent.store.flush();
1486
1602
  if (wasLocked && inboxUrl) await agent.remote.setAcl(inboxUrl, ['Append']).catch(() => {});
1603
+ if (wasFronted) {
1604
+ // Going home renames every id back to the pod: key and process
1605
+ // follow, the same way attach came.
1606
+ await restampKeys(podActorId());
1607
+ json(res, 200, { ok: true, mode: 'off', forgotten: true, restarting: true });
1608
+ selfRestart('restarting under the pod\'s own ids');
1609
+ return;
1610
+ }
1487
1611
  await agent.publisher?.publishProfile(); // re-advertise the pod inbox
1488
1612
  return json(res, 200, { ok: true, mode: 'off', forgotten: true });
1489
1613
  }
1490
- return json(res, 400, { error: 'action must be configure, mode or forget' });
1614
+ return json(res, 400, { error: 'action must be configure, mode, check, attach or forget' });
1491
1615
  }
1492
1616
  // Symmetrical with /block, and open for the same reason: a block made
1493
1617
  // by mistake is worth undoing before federation is even configured.
@@ -1534,14 +1658,17 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1534
1658
  return { handler, masto, c2s, streaming };
1535
1659
  }
1536
1660
 
1537
- export function startAdmin({ port, gateToken, agent, log = console.log, handle = null, tls = null }) {
1661
+ export function startAdmin({ port, gateToken, agent, log = console.log, handle = null, tls = null,
1662
+ // Injectable, so a test can put a checkout ahead of the running process
1663
+ // without editing the package.json of the machine running the test.
1664
+ versionOnDisk = () => localVersion(projectRoot) }) {
1538
1665
  const gate = makeGate(gateToken);
1539
1666
  // Live, so the named origin appears the moment connect() reads the handle
1540
1667
  // out of pod state — including for the OAuth redirect check in MastoApi.
1541
1668
  // The https listener's port joins the authority set: same names, second port.
1542
1669
  const allowed = new Authorities(port, handle);
1543
1670
  agent.authorities = allowed;
1544
- const { handler, streaming } = buildAdminSurface({ agent, gate, allowed, log, port, handle });
1671
+ const { handler, streaming } = buildAdminSurface({ agent, gate, allowed, log, port, handle, versionOnDisk });
1545
1672
 
1546
1673
  // Loopback both ways: the canonical URL is https://localhost:<port>/, and
1547
1674
  // "localhost" resolves to ::1 on many systems before falling back to IPv4 —
package/lib/lease.mjs CHANGED
@@ -29,6 +29,7 @@ export class Lease {
29
29
  this.stopped = false;
30
30
  this.timer = null;
31
31
  this.heldUntil = 0; // wall-clock end of the lease we last wrote
32
+ this.denied = null; // why the last acquire() said no: 'unreadable' | 'held'
32
33
  }
33
34
 
34
35
  // null means the lease document is NOT THERE — nobody holds it. UNREADABLE
@@ -74,16 +75,21 @@ export class Lease {
74
75
  const cur = await this.readFresh();
75
76
  // Cannot read it: stay a viewer. A viewer that should have been active is
76
77
  // an inconvenience; two agents both draining is the destructive one.
77
- if (cur === UNREADABLE) { this.log('lease unreadable — staying a viewer'); return false; }
78
- if (cur && cur.holder !== this.id && Date.now() < cur.expiresAt) return false;
78
+ if (cur === UNREADABLE) {
79
+ this.denied = 'unreadable';
80
+ this.log('lease unreadable — staying a viewer'); return false;
81
+ }
82
+ if (cur && cur.holder !== this.id && Date.now() < cur.expiresAt) { this.denied = 'held'; return false; }
79
83
  const doc = { holder: this.id, expiresAt: Date.now() + TTL_MS };
80
- if (!await this.write(doc)) return false;
84
+ if (!await this.write(doc)) { this.denied = 'held'; return false; }
81
85
  const confirm = await this.readFresh();
82
- if (confirm === UNREADABLE || confirm?.holder !== this.id) return false;
86
+ if (confirm === UNREADABLE) { this.denied = 'unreadable'; return false; }
87
+ if (confirm?.holder !== this.id) { this.denied = 'held'; return false; }
83
88
  // From the document we WROTE, and only once the confirm agrees. Recomputing
84
89
  // it here would claim a lease that outlives what the pod's copy grants, by
85
90
  // however long the PUT and the confirming GET took.
86
91
  this.heldUntil = doc.expiresAt;
92
+ this.denied = null;
87
93
  return true;
88
94
  }
89
95
 
package/lib/mastoapi.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import crypto from 'node:crypto';
14
14
  import * as social from './social.mjs';
15
- import { sanitizeHtml, followsNeedApproval } from './wire.mjs';
15
+ import { sanitizeHtml, followsNeedApproval, publicHandle } from './wire.mjs';
16
16
  import { authorOf } from './intake.mjs';
17
17
  import { profileUrl, postUrl } from './bskyfeed.mjs';
18
18
  import { Push } from './webpush.mjs';
@@ -201,7 +201,7 @@ export class MastoApi {
201
201
  // No fallback handle. `account()` already reads config for self, so a
202
202
  // literal here only ever supplies a name that is not this actor's — and it
203
203
  // was a real person's, so every install with no handle called itself jeff.
204
- return this.account(this.urls.actor, { selfAcct: this.store.getConfig()?.handle || null });
204
+ return this.account(this.urls.actor, { selfAcct: publicHandle(this.store.getConfig()) });
205
205
  }
206
206
 
207
207
  account(actorUrl, { selfAcct } = {}) {
@@ -209,7 +209,7 @@ export class MastoApi {
209
209
  let host = '', user = cached.preferredUsername || '';
210
210
  try { host = new URL(actorUrl).host; if (!user) user = new URL(actorUrl).pathname.split('/').pop(); } catch {}
211
211
  const self = actorUrl === this.urls?.actor;
212
- if (self) user = selfAcct || this.store.getConfig()?.handle || user;
212
+ if (self) user = selfAcct || publicHandle(this.store.getConfig()) || user;
213
213
  return {
214
214
  id: this.store.idFor(actorUrl),
215
215
  username: user,
package/lib/publisher.mjs CHANGED
@@ -75,7 +75,7 @@ export class Publisher {
75
75
  const gw = this.config.gateway;
76
76
  const gwActive = gw && gw.url && gw.mode && gw.mode !== 'off';
77
77
  const actorDoc = wire.actorDoc({
78
- urls, handle: this.config.handle, name: this.config.name, publicKeyPem: this.publicKeyPem,
78
+ urls, handle: wire.publicHandle(this.config), name: this.config.name, publicKeyPem: this.publicKeyPem,
79
79
  movedTo: this.config.movedTo || null, kind: this.config.kind,
80
80
  approveJoins: wire.followsNeedApproval(this.config),
81
81
  assertionKey: this.assertionKey,
@@ -173,7 +173,7 @@ export class Publisher {
173
173
  await this.remote.setAcl(urls.notes, ['Read']);
174
174
 
175
175
  await this.publishCollections({ ...ALL_COLLECTIONS, force });
176
- const updated = await this.announceProfileChange(actor);
176
+ const updated = await this.announceProfileChange(actor, { force });
177
177
  await this.local.writeSettings({ handle: this.config.handle, actorUrl: urls.actor });
178
178
  await this.ensurePrivateAcls();
179
179
  const unreachable = await this.verifyPublicSurface();
@@ -188,9 +188,13 @@ export class Publisher {
188
188
  this.store.write('published.json',
189
189
  { ...this.store.read('published.json', {}), surfaceDigest: surface });
190
190
  }
191
- this.log(wire.webfingerHost(urls.base)
192
- ? `profile published: @${this.config.handle}@${host} → ${urls.actor}`
193
- : `profile published → ${urls.actor} — NOT discoverable as @${this.config.handle}@${host}: `
191
+ // Named as the fediverse sees it: a fronted actor's name and host are the
192
+ // front's, not the pod's.
193
+ const pubName = wire.publicHandle(this.config);
194
+ const pubHost = this.config.gateway?.frontActor ? new URL(this.config.gateway.frontActor).host : host;
195
+ this.log(this.config.gateway?.frontActor || wire.webfingerHost(urls.base)
196
+ ? `profile published: @${pubName}@${pubHost} → ${urls.actor}`
197
+ : `profile published → ${urls.actor} — NOT discoverable as @${pubName}@${pubHost}: `
194
198
  + 'this pod is a path on a shared host, and WebFinger is only answered at a host root');
195
199
  return { unreachable, updated };
196
200
  }
@@ -208,10 +212,12 @@ export class Publisher {
208
212
  // A silent first publish was considered and is WRONG here: it would spend a
209
213
  // real edit doing nothing but recording a digest, and that edit is exactly the
210
214
  // one whose invisibility this fixes.
211
- async announceProfileChange(actor) {
215
+ async announceProfileChange(actor, { force = false } = {}) {
212
216
  const digest = crypto.createHash('sha256').update(JSON.stringify(actor)).digest('hex').slice(0, 32);
213
217
  const seen = this.store.read('published.json', {});
214
- if (seen.actorDigest === digest) return 0;
218
+ // A forced republish is the operator saying TELL THE WORLD — the digest
219
+ // gate is for silent no-op saves, not for that.
220
+ if (seen.actorDigest === digest && !force) return 0;
215
221
  this.store.write('published.json', { ...seen, actorDigest: digest, at: new Date().toISOString() });
216
222
  const inboxes = [...new Set(this.store.getContacts().followers
217
223
  .map(f => f.sharedInbox || f.inbox).filter(Boolean))];
@@ -350,7 +356,7 @@ export class Publisher {
350
356
  this.config.movedTo = target; // so the republish below carries it
351
357
  this.store.setConfig({ ...this.store.getConfig(), movedTo: target, movedAt: at });
352
358
  await this.remote.putJson(urls.actor, wire.actorDoc({
353
- urls, handle: this.config.handle, name: this.config.name,
359
+ urls, handle: wire.publicHandle(this.config), name: this.config.name,
354
360
  publicKeyPem: this.publicKeyPem, movedTo: target, kind: this.config.kind,
355
361
  approveJoins: wire.followsNeedApproval(this.config),
356
362
  assertionKey: this.assertionKey,
package/lib/wire.mjs CHANGED
@@ -21,6 +21,12 @@ export const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
21
21
  // passing advertised ids and the writes still land on the pod. With no
22
22
  // `publicBase` the returned object is byte-identical to before — the invariant
23
23
  // the smoke suite pins.
24
+ // The handle the fediverse sees: a fronted identity's name is the front's.
25
+ export function publicHandle(config) {
26
+ return config?.gateway?.frontActor?.match(/\/u\/([^/]+)\/ap\/actor\/?$/)?.[1]
27
+ || config?.handle || null;
28
+ }
29
+
24
30
  export function apUrls(remotePod, root = 'activitypods-js/', { publicBase = null } = {}) {
25
31
  const base = remotePod.endsWith('/') ? remotePod : remotePod + '/';
26
32
  const home = base + (!root || root.endsWith('/') ? root : root + '/');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "0.9.0",
3
+ "version": "0.11.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",
package/run-agent.mjs CHANGED
@@ -47,7 +47,7 @@ import { Lease } from './lib/lease.mjs';
47
47
  import { startAdmin } from './lib/admin.mjs';
48
48
  import { exposureProblem, hostLabel } from './lib/guard.mjs';
49
49
  import { pendingSteps } from './lib/migrate.mjs';
50
- import { apUrls, assertionKeyId } from './lib/wire.mjs';
50
+ import { apUrls, assertionKeyId , publicHandle } from './lib/wire.mjs';
51
51
  import { followActor, unfollowActor, resolveHandle } from './lib/social.mjs';
52
52
 
53
53
  export class Agent {
@@ -358,7 +358,9 @@ export class Agent {
358
358
  this.intake.bskyGroup = null;
359
359
  if (this.viewer) {
360
360
  this.startViewer();
361
- this.log(`another agent is active for this pod — viewing as @${config.handle} (read-only)`);
361
+ this.log(this.lease.denied === 'unreadable'
362
+ ? `the pod cannot be read, so the lease is unknown — viewing as @${config.handle} (read-only)`
363
+ : `another agent is active for this pod — viewing as @${config.handle} (read-only)`);
362
364
  return true;
363
365
  }
364
366
  await this.startActive({ repair });
@@ -537,7 +539,9 @@ export class Agent {
537
539
  this.schedTimer.unref();
538
540
  // A CSV import interrupted by a restart or a handoff picks back up here.
539
541
  this.importer?.resume();
540
- this.log(`federating as @${this.store.getConfig()?.handle}@${new URL(this.urls.base).host}`);
542
+ // The identity the fediverse actually sees: a fronted actor's name and
543
+ // host are the front's, not the pod's.
544
+ this.log(`federating as @${publicHandle(this.store.getConfig())}@${new URL(this.urls.actor).host}`);
541
545
  if (this.renamed) {
542
546
  // The display name lives in the actor document, so a rename only
543
547
  // reaches other servers once that is republished.
@@ -141,7 +141,8 @@ function render() {
141
141
  ['local host', (origins.named || origins.loopback || `http://localhost:${config.port}`)
142
142
  .replace(/\/$/, '')],
143
143
  ];
144
- if (config.update || config.pendingUpgrade?.length) rows.push(['software', 'ctl']);
144
+ if (config.version || config.update || config.pendingUpgrade?.length) rows.push(['software', 'ctl']);
145
+ rows.push(['gateway', 'ctl']);
145
146
  if (config.quiescedAt) rows.push(['parked since', config.quiescedAt]);
146
147
  if (config.movedTo) rows.push(['moved to', config.movedTo]);
147
148
  // A person gates followers here; a group's gate is the joins control on its
@@ -185,13 +186,23 @@ function render() {
185
186
  FOLLOWS_CTL.hidden = false;
186
187
  FOLLOWS_PICK.value = config.autoAcceptFollows ? 'auto' : 'approve';
187
188
  }
189
+ if (k === 'gateway') {
190
+ dd.textContent = '';
191
+ dd.append(GATEWAY_CTL);
192
+ refreshGateway();
193
+ }
188
194
  if (k === 'software') {
189
195
  dd.textContent = '';
190
196
  dd.append(UPDATE_CTL);
191
197
  UPDATE_CTL.hidden = false;
192
198
  const u = config.update;
199
+ // The version this agent is running, never the one sitting in the
200
+ // checkout — saying otherwise would name a version nobody is serving.
201
+ const running = config.version || u?.current || null;
193
202
  const words = [];
194
- if (u) words.push(u.available ? `FediPod ${u.current} — ${u.latest} available` : `FediPod ${u.current}`);
203
+ if (running) words.push(u?.available ? `FediPod ${running} — ${u.latest} available` : `FediPod ${running}`);
204
+ if (config.versionOnDisk && running && config.versionOnDisk !== running)
205
+ words.push(`${config.versionOnDisk} is on disk — restart to run it`);
195
206
  if (config.pendingUpgrade?.length) words.push('older data layout — run `fedipod upgrade` in a terminal');
196
207
  UPDATE_WORD.textContent = words.join('; ');
197
208
  UPDATE_GO.hidden = !u?.available;
@@ -931,9 +942,95 @@ async function renderInbox() {
931
942
  panel.hidden = false;
932
943
  }
933
944
 
934
- // The inbox-gateway / multi-user-front operator panel is parked: not shown in
935
- // the UI yet. The backend routes (GET/POST /gateway) still exist and work, so
936
- // this can be re-added when the feature is ready to expose.
945
+ // The gateway, as a facts row under software: attach through a multi-user
946
+ // front with this agent's own credential, detach back to the pod inbox. The
947
+ // forms live in the floating window, like every other disclosure.
948
+ const GATEWAY_CTL = $('gateway-ctl'); // held: it rides into a generated row
949
+ const GATEWAY_WORD = $('gateway-word');
950
+ const GW_OPEN_ATTACH = $('gateway-open-attach');
951
+ const GW_OPEN_DETACH = $('gateway-open-detach');
952
+ let gwState = null;
953
+ async function refreshGateway() {
954
+ const { status, json: g } = await api('/gateway');
955
+ if (status !== 200 || !g) return;
956
+ gwState = g;
957
+ GATEWAY_CTL.hidden = false;
958
+ if (g.configured) {
959
+ let host = g.url;
960
+ try { host = new URL(g.url).host; } catch { /* show it as-is */ }
961
+ const frontName = g.frontActor?.match(/\/u\/([^/]+)\/ap\/actor\/?$/)?.[1];
962
+ GATEWAY_WORD.textContent = frontName ? `${host} — publishing as @${frontName}@${host}` : host;
963
+ GW_OPEN_ATTACH.hidden = true;
964
+ GW_OPEN_DETACH.hidden = false;
965
+ } else {
966
+ GATEWAY_WORD.textContent = '';
967
+ GW_OPEN_ATTACH.hidden = false;
968
+ GW_OPEN_DETACH.hidden = true;
969
+ }
970
+ }
971
+ const gwShape = () => document.querySelector('input[name=gwShape]:checked')?.value || 'pod';
972
+ function gwPreviews() {
973
+ $('gw-pod-preview').textContent = config?.address || `@${config?.handle || 'you'}@your.pod`;
974
+ try { $('gw-front-host').textContent = new URL($('gw-front').value.trim()).host; }
975
+ catch { /* not a URL yet — the placeholder host stands */ }
976
+ }
977
+ function gwShapeChanged() { gwPreviews(); gwCheck(); }
978
+ for (const r of document.querySelectorAll('input[name=gwShape]')) r.addEventListener('change', gwShapeChanged);
979
+ // Typing in the blank IS choosing that shape.
980
+ $('gw-name').addEventListener('focus', () => {
981
+ const r = document.querySelector('input[name=gwShape][value=front]');
982
+ if (!r.checked) { r.checked = true; gwShapeChanged(); }
983
+ });
984
+ GW_OPEN_ATTACH.addEventListener('click', () => {
985
+ gwShapeChanged();
986
+ solWindow.show('gateway-attach-form', 'Attach to a gateway');
987
+ });
988
+ GW_OPEN_DETACH.addEventListener('click', () => {
989
+ $('gw-detach-fronted').hidden = !gwState?.frontActor;
990
+ $('gw-detach-plain').hidden = !!gwState?.frontActor;
991
+ solWindow.show('gateway-detach-form', 'Detach from gateway');
992
+ });
993
+ // Live availability, asked through the agent (the front answers it without CORS).
994
+ let gwTimer = null;
995
+ function gwCheck() {
996
+ clearTimeout(gwTimer);
997
+ const front = $('gw-front').value.trim().replace(/\/+$/, '');
998
+ const name = $('gw-name').value.trim().toLowerCase();
999
+ $('gw-name-msg').textContent = ''; $('gw-name-msg').className = 'hint';
1000
+ if (gwShape() !== 'front' || !front || !name) return;
1001
+ gwTimer = setTimeout(async () => {
1002
+ const { status, json } = await postJson('/gateway', { action: 'check', front, handle: name });
1003
+ if (status !== 200 || !json) return;
1004
+ $('gw-name-msg').textContent = json.available
1005
+ ? `${name} is free at ${front.replace(/^https?:\/\//, '')}`
1006
+ : (json.reason || 'that name is taken');
1007
+ $('gw-name-msg').className = json.available ? 'hint' : 'warn';
1008
+ }, 300);
1009
+ }
1010
+ $('gw-front').addEventListener('input', () => { gwPreviews(); gwCheck(); });
1011
+ $('gw-name').addEventListener('input', () => { gwPreviews(); gwCheck(); });
1012
+ $('gw-attach').addEventListener('click', async () => {
1013
+ const front = $('gw-front').value.trim().replace(/\/+$/, '');
1014
+ const fronted = gwShape() === 'front';
1015
+ const name = $('gw-name').value.trim().toLowerCase();
1016
+ if (fronted && !name) { say('give the handle you want at the gateway', 'err'); return; }
1017
+ $('gw-attach').disabled = true;
1018
+ // Pod-based sends no name: the door's label is the agent's business, not
1019
+ // the user's, and the agent picks a free variant by itself.
1020
+ const r = await write('/gateway',
1021
+ { action: 'attach', front, ...(fronted ? { handle: name, fronted: true } : {}) },
1022
+ fronted ? 'attached — the agent is restarting to publish under the gateway handle; reload in a moment'
1023
+ : 'attached — your mail now arrives through the gateway, filtered');
1024
+ $('gw-attach').disabled = false;
1025
+ if (r) { closePanels(); refreshGateway(); }
1026
+ });
1027
+ $('gw-detach').addEventListener('click', async () => {
1028
+ const fronted = !!gwState?.frontActor;
1029
+ const r = await write('/gateway', { action: 'forget' },
1030
+ fronted ? 'detached — the agent is restarting under your pod\'s own name; reload in a moment'
1031
+ : 'detached — the actor was republished advertising your pod\'s own inbox');
1032
+ if (r) { closePanels(); refreshGateway(); }
1033
+ });
937
1034
 
938
1035
  $('inbox-keep').addEventListener('click', () => {
939
1036
  dismissed = true;
@@ -15,14 +15,14 @@
15
15
  :root { color-scheme: light dark; font-size: 112.5%;
16
16
  --bar-col: 1280px; /* the bar centres on the same column */
17
17
  --link: #1a4f8a; --heading: #5b3a8e;
18
- --ink: #1b1c1e; --muted: #5f6368;
18
+ --ink: #1b1c1e; --muted: #4a4e53;
19
19
  --btn-bg: #f4efe6; --btn-bg-hover: #ece3d2; --btn-bg-active: #e2d5bd; --btn-edge: #8a7a61;
20
20
  --field-bg: #ffffff; --field-bg-hover: #f7f4ee; --field-edge: #767676;
21
21
  --danger: #b00020; --ring: var(--link);
22
22
  --chevron: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6'><path d='M1 1l4 4 4-4' fill='none' stroke='%23444444' stroke-width='1.6' stroke-linecap='round'/></svg>"); }
23
23
  @media (prefers-color-scheme: dark) {
24
24
  :root { --link: #7fb3e8; --heading: #b9a3e3;
25
- --ink: #e8eaed; --muted: #a5a9ad;
25
+ --ink: #e8eaed; --muted: #b9bdc2;
26
26
  --btn-bg: #35302a; --btn-bg-hover: #423b31; --btn-bg-active: #4d4436; --btn-edge: #9a8d7a;
27
27
  --field-bg: #383d44; --field-bg-hover: #40464e; --field-edge: #9aa0a6;
28
28
  --danger: #ff8a8a;
@@ -98,7 +98,8 @@ p { margin: .5rem 0; }
98
98
  fieldset { border: 1px solid #bbb; border-radius: .4rem; margin: 1rem 0; padding: .75rem 1rem 1rem; }
99
99
  legend { padding: 0 .3rem; font-weight: 600; color: var(--heading); }
100
100
  label { display: block; margin: .75rem 0 .2rem; }
101
- .hint { color: var(--muted); font-size: .9rem; margin: .15rem 0 0; }
101
+ .hint { color: var(--muted); font-size: 1rem; margin: .15rem 0 0; }
102
+ .choice input[type=text] { width: 9ch; display: inline-block; padding: .15rem .3rem; }
102
103
  /* SETTINGS: light wells, unmistakably fields, in both schemes. */
103
104
  input[type=text], input[type=email], input[type=url], input[type=password], textarea {
104
105
  font: inherit; width: 100%; padding: .5rem; box-sizing: border-box;
@@ -314,9 +315,13 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
314
315
  </span>
315
316
  </section>
316
317
 
317
- <!-- The inbox-gateway / multi-user-front controls are deliberately not shown
318
- in the UI yet. The backend routes (GET/POST /gateway) exist and work; the
319
- operator surface is parked until the feature is ready to expose. -->
318
+ <span id="gateway-ctl" hidden>
319
+ <span id="gateway-word"></span>
320
+ <button type="button" id="gateway-open-attach" class="inline" hidden
321
+ title=" Attach this account to a mail-filtering gateway">Attach to a gateway</button>
322
+ <button type="button" id="gateway-open-detach" class="inline danger" hidden
323
+ title=" Go back to your pod's own inbox">Detach from gateway</button>
324
+ </span>
320
325
 
321
326
  <section id="pane-group" hidden>
322
327
  <!-- Both queues are consequences of a moderation setting: with the setting off
@@ -421,6 +426,45 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
421
426
  <!-- Every consequence is written here rather than assembled in script: the
422
427
  page asking "are you sure" has to say what it is sure about, and this is
423
428
  the only record of it. Script picks which block to show, nothing else. -->
429
+ <!-- The gateway popups: attach proves the pod with this agent's own credential;
430
+ detach republishes the pod's own inbox. -->
431
+ <form id="gateway-attach-form" hidden>
432
+ <p class="hint">A gateway filters your fediverse mail before it reaches your pod: each
433
+ delivery is verified at its door, spam is dropped, and the rest lands in your pod as
434
+ before. Attaching proves the pod with this agent's own credential — no password
435
+ leaves this machine.</p>
436
+ <label for="gw-front">gateway</label>
437
+ <input type="url" id="gw-front" placeholder="https://fedipod.net" autocomplete="off">
438
+ <fieldset>
439
+ <legend>Your fediverse handle</legend>
440
+ <label class="choice"><input type="radio" name="gwShape" value="pod" checked>
441
+ Keep my pod-based handle: <strong id="gw-pod-preview"></strong><br>
442
+ <span class="hint">Your address stays tied to your own pod — you can drop the
443
+ gateway any time and keep everything.</span></label>
444
+ <label class="choice"><input type="radio" name="gwShape" value="front">
445
+ Create a handle at the gateway:
446
+ @<input type="text" id="gw-name" placeholder="name" autocomplete="off"
447
+ autocapitalize="off" spellcheck="false">@<span id="gw-front-host">fedipod.net</span><br>
448
+ <span class="hint">Your address lives on the gateway's domain, but not your data
449
+ or key; you can change pods later and keep the handle.</span>
450
+ <span id="gw-name-msg" class="hint"></span></label>
451
+ </fieldset>
452
+ <p>
453
+ <button id="gw-attach" type="button" class="primary"
454
+ title=" Create the gateway account and point your mail through its door">Attach</button>
455
+ </p>
456
+ </form>
457
+ <form id="gateway-detach-form" hidden>
458
+ <p class="hint" id="gw-detach-plain">Deliveries go back to your pod's own inbox — the
459
+ actor is republished advertising it. Your name and data never moved.</p>
460
+ <p class="warn" id="gw-detach-fronted" hidden>This identity publishes under the gateway's
461
+ name. Detaching moves every published id back to the pod — a rename other servers
462
+ see, not just a mail change.</p>
463
+ <p>
464
+ <button id="gw-detach" type="button" class="primary"
465
+ title=" Forget the gateway and republish the actor with the pod inbox">Detach</button>
466
+ </p>
467
+ </form>
424
468
  <form id="confirm-form" hidden>
425
469
  <div id="warn-rotate-key" class="warn" hidden>
426
470
  <p>A new keypair replaces the one in this home and the actor is republished so other
@@ -3,422 +3,41 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>FediPod — keep your name, lose the noise</title>
6
+ <title>FediPod — access the Fediverse and ATProto from a Solid pod</title>
7
7
  <style>
8
8
  :root { color-scheme: light dark; --line:#d9d4c7; --dim:#45443d; --accent:#6a8a5a; }
9
9
  @media (prefers-color-scheme: dark) { :root { --dim:#c2c0b4; } }
10
10
  body { font: 19px/1.6 system-ui, sans-serif; max-width: 44rem; margin: 2rem auto; padding: 0 1.1rem; }
11
11
  h1 { font-size: 1.7rem; margin: 0 0 .2rem; }
12
- h2 { font-size: 1.2rem; margin: 0 0 .4rem; }
12
+ h2 { font-size: 1.2rem; margin: 1.4rem 0 .4rem; }
13
13
  p.lede { color: var(--dim); margin-top: 0; }
14
- fieldset { border: 1px solid var(--line); border-radius: 12px; padding: 1rem 1.1rem; margin: 1rem 0; }
15
- legend { font-weight: 600; padding: 0 .4rem; }
16
- .choices { display: flex; flex-direction: column; gap: .5rem; }
17
- .choices button { padding: .7rem .8rem; border: 1px solid var(--line); border-radius: 10px;
18
- background: transparent; font: inherit; cursor: pointer; text-align: left; }
19
- .choices button[aria-pressed="true"] { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
20
- .choices button b { display: block; }
21
- .choices button span { color: var(--dim); font-size: .95rem; }
22
- label { display: block; font-weight: 600; margin: .8rem 0 .2rem; }
23
- input[type=text], input[type=url] { width: 100%; padding: .5rem .6rem; font: inherit;
24
- border: 1px solid var(--line); border-radius: 8px; box-sizing: border-box; }
25
- .msg { min-height: 1.3em; font-size: .95rem; margin-top: .3rem; }
26
- .msg.ok { color: var(--accent); } .msg.no { color: #a4433a; }
27
14
  .hint { color: var(--dim); font-size: .95rem; }
28
- .primary { background: var(--accent); color: #fff; border: none; border-radius: 9px;
29
- padding: .6rem 1.1rem; font: inherit; cursor: pointer; }
30
- .primary[disabled] { opacity: .5; cursor: not-allowed; }
31
- [hidden] { display: none !important; }
32
15
  code { background: rgba(120,120,120,.12); padding: .05em .35em; border-radius: 4px; }
33
16
  pre { background: rgba(120,120,120,.12); padding: .7rem .9rem; border-radius: 8px; overflow-x: auto; }
34
17
  ol.steps li { margin: .5rem 0; }
18
+ [hidden] { display: none !important; }
35
19
  </style>
36
20
  </head>
37
21
  <body>
38
22
  <h1>FediPod - join the fediverse from a Solid pod.</h1>
39
- <p class="lede">Welcome to the <strong>FediPod.net gateway</strong>. Here, you can get a free pod and fediverse account or link to your existing pod/account. This gateway is a mail filter for accounts that stay yours. Your name, key and data live on your own pod; this box only receives your fediverse mail, verifies each delivery, drops spam and passes the rest to your pod.</p>
23
+ <p class="lede">Welcome to <b>FediPod</b>! Here, you can get a free pod and fediverse account or link to your existing pod/account.</p>
24
+
25
+ <p class="hint" id="current-version" hidden>Current FediPod: <strong id="current-version-num"></strong><br>
26
+ — install or update an existing install with <code id="current-version-cmd"></code></p>
40
27
 
41
28
  <p>
42
29
  FediPod is experimental and free to use. See the <a href="https://github.com/jeff-zucker/FediPod">FediPod repository</a> for details.
43
30
  </p>
44
31
 
45
- <p class="hint" id="current-version" hidden>Current FediPod: <strong id="current-version-num"></strong>
46
- — update an existing install with <code id="current-version-cmd"></code></p>
47
-
48
- <fieldset>
49
- <legend>What do you want to do?</legend>
50
- <div class="choices" role="group" aria-label="Path">
51
- <button type="button" id="pick-solo" aria-pressed="false"><b>Create a new solo account</b><span>a fediverse account for one person, on a pod you own</span></button>
52
- <button type="button" id="pick-group" aria-pressed="false"><b>Create a new group account</b><span>an account people join &mdash; it carries members' posts to the whole group</span></button>
53
- <button type="button" id="pick-manage" aria-pressed="false"><b>Manage an existing account</b><span>attach it to this gateway, detach it, or transfer in</span></button>
54
- </div>
55
- </fieldset>
56
-
57
- <!-- create: the signup itself — prove the pod, take the name, then install -->
58
- <form id="create-form" hidden>
59
- <h2 id="create-title">Create a new solo account</h2>
60
- <p class="hint" id="create-group-hint" hidden>A group is an account people join: members post to it, and it
61
- carries each member's post to everyone in the group. Mastodon users can join with no pod of their own.
62
- A group needs a pod of its own.</p>
63
- <p class="hint">Your account lives on a pod you own; this gateway filters its mail.</p>
64
-
65
- <label for="create-pod">Your pod address</label>
66
- <input type="url" id="create-pod" autocomplete="off" placeholder="https://you.solidcommunity.net/">
67
- <p class="hint">No pod yet? Any Solid pod host that offers <em>https subdomains</em> works, or a Community
68
- Solid Server you run yourself. Get the pod, then come back here.</p>
69
- <p id="create-pods" class="hint" hidden>This host also offers pods — you can take one instead of bringing
70
- your own; it stays exportable, so you can leave with everything.</p>
71
-
72
- <label for="create-issuer">Where you log in (your identity provider)</label>
73
- <input type="url" id="create-issuer" autocomplete="off" placeholder="https://solidcommunity.net">
74
-
75
- <label for="create-handle">Your handle (the name in your address; permanent)</label>
76
- <input type="text" id="create-handle" autocomplete="off" autocapitalize="off" spellcheck="false"
77
- inputmode="latin" placeholder="you" aria-describedby="create-handle-msg">
78
- <p class="msg" id="create-handle-msg" aria-live="polite"></p>
79
-
80
- <label>Your fediverse address</label>
81
- <div class="choices" role="group" aria-label="Address">
82
- <button type="button" id="addr-pod" aria-pressed="false"><b id="addr-pod-name">@you@your.pod</b><span>your handle is tied to your pod &mdash; you can drop the gateway and keep everything</span></button>
83
- <button type="button" id="addr-front" aria-pressed="false"><b id="addr-front-name">@you@this.host</b><span>your handle is tied to the gateway &mdash; you can move pods and keep your handle</span></button>
84
- </div>
85
-
86
- <p style="margin-top:1.1rem">
87
- <button type="button" id="create-continue" class="primary" disabled>Sign in with your pod &amp; create</button>
88
- </p>
89
- <p class="hint">Signing in proves the pod is yours — no password ever reaches this gateway.</p>
90
- <p class="hint" id="create-note" hidden></p>
91
- </form>
92
-
93
- <!-- what a finished signup hands over: the one install command -->
94
- <section id="create-done" hidden></section>
95
-
96
- <!-- manage: the existing-account actions, one panel each -->
97
- <section id="manage-form" hidden>
98
- <div class="choices" role="group" aria-label="Manage">
99
- <button type="button" id="pick-attach" aria-pressed="false"><b>Attach to this gateway</b><span>filter your mail through this door &mdash; your address stays yours</span></button>
100
- <button type="button" id="pick-detach" aria-pressed="false"><b>Detach from this gateway</b><span>go back to unfiltered delivery</span></button>
101
- <button type="button" id="pick-transfer" aria-pressed="false"><b>Transfer a fediverse account to FediPod</b><span>move here from Mastodon and friends</span></button>
102
- </div>
103
- </section>
104
-
105
- <!-- attach: an existing FediPod points its inbox here; identity stays put -->
106
- <form id="attach-form" hidden>
107
- <h2>Attach an existing FediPod account</h2>
108
- <p class="hint">Your account stays <code>@you@your.pod</code> — other servers keep finding you at your own
109
- address. Attaching only changes where your <em>mail</em> arrives: your actor advertises this gateway's door
110
- as its inbox, and everything that passes verification lands in your pod as before.</p>
111
-
112
- <label for="pod-url">Your pod address</label>
113
- <input type="url" id="pod-url" autocomplete="off" placeholder="https://you.solidcommunity.net/">
114
- <p class="hint">If your FediPod uses a custom root, give the full home URL (ending in <code>/</code>);
115
- a bare pod address assumes the default <code>activitypods-js/</code>.</p>
116
-
117
- <label for="issuer">Where you log in (your identity provider)</label>
118
- <input type="url" id="issuer" autocomplete="off" placeholder="https://solidcommunity.net">
119
-
120
- <label for="slug">Your door's name here</label>
121
- <input type="text" id="slug" autocomplete="off" autocapitalize="off" spellcheck="false"
122
- inputmode="latin" placeholder="you" aria-describedby="slug-msg">
123
- <p class="msg" id="slug-msg" aria-live="polite"></p>
124
- <p class="hint">Only the door is named here — it is a routing address for servers, not your handle.</p>
125
-
126
- <p style="margin-top:1.1rem">
127
- <button type="button" id="attach-continue" class="primary" disabled>Sign in with your pod &amp; attach</button>
128
- </p>
129
- <p class="hint">Signing in proves the pod is yours — no password ever reaches this gateway.</p>
130
- <p class="hint" id="attach-note" hidden></p>
131
- </form>
132
-
133
- <!-- detach: done on the user's own agent; the gateway cannot reach in -->
134
- <section id="detach-form" hidden>
135
- <h2>Detach an existing FediPod account</h2>
136
- <p class="hint">Detaching is yours to do, not this gateway's — your agent republishes your actor with your
137
- pod's own inbox, and deliveries go straight there again. On your machine:</p>
138
- <pre>node bin/fedipod.mjs gateway --detach</pre>
139
- <p class="hint">Your name and data never moved, so nothing else changes. The door you had here goes quiet
140
- and its name can be reused.</p>
141
- </section>
142
-
143
- <!-- transfer: the standard fediverse move, aimed at a FediPod -->
144
- <section id="transfer-form" hidden>
145
- <h2>Transfer a fediverse account to FediPod</h2>
146
- <p class="hint">The fediverse's standard migration works toward a FediPod: your followers move over
147
- automatically, and your old profile redirects. Posts stay on the old server — no fediverse migration
148
- carries them.</p>
149
- <ol class="steps">
150
- <li><strong>Have a FediPod</strong> — create one first (the first option above).</li>
151
- <li>On your agent's admin page, set <strong>new followers</strong> to <em>accepted automatically</em>,
152
- so the wave of arriving followers doesn't queue.</li>
153
- <li>Tell your FediPod about the old account:
154
- <pre>node bin/fedipod.mjs alias --add @you@old.server</pre></li>
155
- <li>On the old server: Preferences → Account → <em>Move to a different account</em>, naming your
156
- FediPod address.</li>
157
- <li>Bring the rest over from the old server's CSV export:
158
- <pre>node bin/fedipod.mjs import following_accounts.csv blocked_accounts.csv muted_accounts.csv lists.csv</pre></li>
159
- </ol>
160
- </section>
161
-
162
- <script src="/solid-client-authn.bundle.js"></script>
163
32
  <script>
164
- const $ = (id) => document.getElementById(id);
165
- let offersPods = false, slugOk = false;
166
-
167
33
  fetch('/api/handle?handle=__probe__').then(r => r.json()).then(d => {
168
- offersPods = !!d.offersPods;
169
- $('create-pods').hidden = !offersPods;
170
34
  if (d.version) {
171
- $('current-version-num').textContent = d.version;
172
- $('current-version-cmd').textContent = `curl -fsSL ${location.origin}/install | sh`;
173
- $('current-version').hidden = false;
35
+ document.getElementById('current-version-num').textContent = d.version;
36
+ document.getElementById('current-version-cmd').textContent = `curl -fsSL ${location.origin}/install | sh`;
37
+ document.getElementById('current-version').hidden = false;
174
38
  }
175
39
  }).catch(() => {});
176
-
177
- const picks = ['pick-solo', 'pick-group', 'pick-manage'];
178
- const panels = { 'pick-solo': 'create-form', 'pick-group': 'create-form',
179
- 'pick-manage': 'manage-form' };
180
- const subPicks = ['pick-attach', 'pick-detach', 'pick-transfer'];
181
- const subPanels = { 'pick-attach': 'attach-form', 'pick-detach': 'detach-form',
182
- 'pick-transfer': 'transfer-form' };
183
- let createKind = 'person';
184
- function subPick(which) {
185
- for (const id of subPicks) {
186
- $(id).setAttribute('aria-pressed', String(id === which));
187
- $(subPanels[id]).hidden = id !== which;
188
- }
189
- }
190
- function pick(which) {
191
- for (const id of picks) $(id).setAttribute('aria-pressed', String(id === which));
192
- for (const panel of new Set(Object.values(panels))) $(panel).hidden = panels[which] !== panel;
193
- $('create-done').hidden = true;
194
- if (which !== 'pick-manage') subPick(null);
195
- if (panels[which] === 'create-form') {
196
- createKind = which === 'pick-group' ? 'group' : 'person';
197
- $('create-title').textContent = createKind === 'group'
198
- ? 'Create a new group account' : 'Create a new solo account';
199
- $('create-group-hint').hidden = createKind !== 'group';
200
- }
201
- }
202
- for (const id of picks) $(id).onclick = () => pick(id);
203
- for (const id of subPicks) $(id).onclick = () => subPick(id);
204
-
205
- // Suggest the door name from the pod's own first label, and the identity
206
- // provider from the pod's apex domain — both still editable.
207
- $('pod-url').addEventListener('input', () => {
208
- try {
209
- const u = new URL($('pod-url').value.trim());
210
- const label = u.hostname.split('.')[0];
211
- if (!$('slug').value && /^[a-z0-9-]{1,63}$/.test(label)) {
212
- $('slug').value = label;
213
- $('slug').dispatchEvent(new Event('input'));
214
- }
215
- if (!$('issuer').value) {
216
- const parts = u.hostname.split('.');
217
- $('issuer').value = 'https://' + (parts.length > 2 ? parts.slice(1).join('.') : u.hostname);
218
- }
219
- } catch { /* not a URL yet */ }
220
- refresh();
221
- });
222
- $('issuer').addEventListener('input', refresh);
223
-
224
- let t = null;
225
- $('slug').addEventListener('input', () => {
226
- slugOk = false; $('slug-msg').textContent = ''; $('slug-msg').className = 'msg';
227
- clearTimeout(t);
228
- const h = $('slug').value.trim().toLowerCase();
229
- if (!h) { refresh(); return; }
230
- t = setTimeout(async () => {
231
- try {
232
- const d = await (await fetch('/api/handle?handle=' + encodeURIComponent(h))).json();
233
- slugOk = d.available;
234
- $('slug-msg').textContent = d.available ? 'this door is free' : (d.reason || 'taken');
235
- $('slug-msg').className = 'msg ' + (d.available ? 'ok' : 'no');
236
- } catch { $('slug-msg').textContent = 'could not check'; $('slug-msg').className = 'msg no'; }
237
- refresh();
238
- }, 250);
239
- });
240
-
241
- function refresh() {
242
- const podOk = /^https:\/\/\S+/.test($('pod-url').value.trim());
243
- const issuerOk = /^https:\/\/\S+/.test($('issuer').value.trim());
244
- $('attach-continue').disabled = !(podOk && slugOk && issuerOk);
245
- }
246
-
247
- // The user's pod home: their agent tree on the pod. A bare pod root gets
248
- // the default root; anything with a path is taken as given.
249
- function podHomeOf(raw) {
250
- const u = new URL(raw.trim());
251
- if (u.pathname === '/') u.pathname = '/activitypods-js/';
252
- else if (!u.pathname.endsWith('/')) u.pathname += '/';
253
- u.search = ''; u.hash = '';
254
- return u.href;
255
- }
256
-
257
- $('attach-continue').onclick = () => {
258
- const auth = window.solidClientAuthentication;
259
- const n = $('attach-note');
260
- if (!auth) { n.hidden = false; n.textContent = 'the sign-in library did not load — reload and try again'; return; }
261
- sessionStorage.setItem('fp-attach', JSON.stringify({
262
- slug: $('slug').value.trim().toLowerCase(),
263
- podHome: podHomeOf($('pod-url').value),
264
- kind: 'person',
265
- }));
266
- auth.login({
267
- oidcIssuer: $('issuer').value.trim(),
268
- redirectUrl: location.origin + '/',
269
- clientName: 'FediPod gateway',
270
- }).catch((e) => { n.hidden = false; n.textContent = 'sign-in failed to start: ' + e.message; });
271
- };
272
-
273
- // ---- the create (signup) form ----
274
- let createSlugOk = false, addrShape = null;
275
- const esc = (s) => String(s).replace(/[&<>"']/g, (c) =>
276
- ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
277
-
278
- // The two address previews follow the handle and pod as they are typed.
279
- function addrPreview() {
280
- const h = $('create-handle').value.trim().toLowerCase() || 'you';
281
- let podHost = 'your.pod';
282
- try { podHost = new URL($('create-pod').value.trim()).hostname; } catch { /* not a URL yet */ }
283
- $('addr-pod-name').textContent = `@${h}@${podHost}`;
284
- $('addr-front-name').textContent = `@${h}@${location.host}`;
285
- }
286
- function createRefresh() {
287
- const podOk = /^https:\/\/\S+/.test($('create-pod').value.trim());
288
- const issuerOk = /^https:\/\/\S+/.test($('create-issuer').value.trim());
289
- $('create-continue').disabled = !(podOk && issuerOk && createSlugOk && addrShape);
290
- }
291
- $('addr-pod').onclick = () => { addrShape = 'pod';
292
- $('addr-pod').setAttribute('aria-pressed', 'true');
293
- $('addr-front').setAttribute('aria-pressed', 'false'); createRefresh(); };
294
- $('addr-front').onclick = () => { addrShape = 'front';
295
- $('addr-front').setAttribute('aria-pressed', 'true');
296
- $('addr-pod').setAttribute('aria-pressed', 'false'); createRefresh(); };
297
-
298
- $('create-pod').addEventListener('input', () => {
299
- try {
300
- const u = new URL($('create-pod').value.trim());
301
- const label = u.hostname.split('.')[0];
302
- if (!$('create-handle').value && /^[a-z0-9-]{1,63}$/.test(label)) {
303
- $('create-handle').value = label;
304
- $('create-handle').dispatchEvent(new Event('input'));
305
- }
306
- if (!$('create-issuer').value) {
307
- const parts = u.hostname.split('.');
308
- $('create-issuer').value = 'https://' + (parts.length > 2 ? parts.slice(1).join('.') : u.hostname);
309
- }
310
- } catch { /* not a URL yet */ }
311
- addrPreview(); createRefresh();
312
- });
313
- $('create-issuer').addEventListener('input', createRefresh);
314
-
315
- let ct = null;
316
- $('create-handle').addEventListener('input', () => {
317
- createSlugOk = false; $('create-handle-msg').textContent = ''; $('create-handle-msg').className = 'msg';
318
- addrPreview();
319
- clearTimeout(ct);
320
- const h = $('create-handle').value.trim().toLowerCase();
321
- if (!h) { createRefresh(); return; }
322
- ct = setTimeout(async () => {
323
- try {
324
- const d = await (await fetch('/api/handle?handle=' + encodeURIComponent(h))).json();
325
- createSlugOk = d.available;
326
- $('create-handle-msg').textContent = d.available ? 'this name is free' : (d.reason || 'taken');
327
- $('create-handle-msg').className = 'msg ' + (d.available ? 'ok' : 'no');
328
- } catch { $('create-handle-msg').textContent = 'could not check'; $('create-handle-msg').className = 'msg no'; }
329
- createRefresh();
330
- }, 250);
331
- });
332
-
333
- $('create-continue').onclick = () => {
334
- const auth = window.solidClientAuthentication;
335
- const n = $('create-note');
336
- if (!auth) { n.hidden = false; n.textContent = 'the sign-in library did not load — reload and try again'; return; }
337
- sessionStorage.setItem('fp-create', JSON.stringify({
338
- slug: $('create-handle').value.trim().toLowerCase(),
339
- podHome: podHomeOf($('create-pod').value),
340
- issuer: $('create-issuer').value.trim(),
341
- kind: createKind,
342
- fronted: addrShape === 'front',
343
- }));
344
- auth.login({
345
- oidcIssuer: $('create-issuer').value.trim(),
346
- redirectUrl: location.origin + '/',
347
- clientName: 'FediPod gateway',
348
- }).catch((e) => { n.hidden = false; n.textContent = 'sign-in failed to start: ' + e.message; });
349
- };
350
-
351
- // Back from the identity provider: finish the signup with the proven login.
352
- (async () => {
353
- const auth = window.solidClientAuthentication;
354
- if (!auth) return;
355
- const info = await auth.handleIncomingRedirect().catch(() => null);
356
- const pending = sessionStorage.getItem('fp-create');
357
- if (!info?.isLoggedIn || !pending) return;
358
- sessionStorage.removeItem('fp-create');
359
- const p = JSON.parse(pending);
360
- pick(p.kind === 'group' ? 'pick-group' : 'pick-solo');
361
- const n = $('create-note');
362
- n.hidden = false;
363
- n.textContent = 'signed in as ' + info.webId + ' — creating…';
364
- const res = await auth.fetch('/api/attach', {
365
- method: 'POST', headers: { 'content-type': 'application/json' },
366
- body: JSON.stringify({ handle: p.slug, podHome: p.podHome, kind: p.kind, fronted: p.fronted }),
367
- }).catch(() => null);
368
- const d = res ? await res.json().catch(() => ({})) : {};
369
- if (res && res.status === 201) {
370
- const address = p.fronted ? d.address : `@${p.slug}@${new URL(p.podHome).hostname}`;
371
- const flags = [
372
- `--gateway ${d.frontActor || d.doorInbox}`,
373
- `--secret ${d.hmacSecret}`,
374
- `--pod ${p.podHome}`,
375
- `--issuer ${p.issuer}`,
376
- `--handle ${p.slug}`,
377
- `--kind ${p.kind}`,
378
- ...(p.fronted ? ['--fronted'] : []),
379
- ];
380
- const cmd = `curl -fsSL ${location.origin}/install | sh -s -- \\\n ` + flags.join(' \\\n ');
381
- $('create-form').hidden = true;
382
- const done = $('create-done');
383
- done.hidden = false;
384
- done.innerHTML = '<h2>' + esc(address) + '</h2>'
385
- + '<p>Created. Now install your agent on the machine it will live on — one command, then start it:</p>'
386
- + '<pre>' + esc(cmd) + '\n\nfedipod start</pre>'
387
- + '<p>Setup opens in your browser with everything already filled in. When it finishes, your '
388
- + 'account is live and your mail arrives through this gateway, filtered.</p>';
389
- } else {
390
- n.textContent = 'signup failed: ' + (d.error || (res ? 'HTTP ' + res.status : 'no response'));
391
- }
392
- })();
393
-
394
- // Back from the identity provider: finish the attach with the proven login.
395
- (async () => {
396
- const auth = window.solidClientAuthentication;
397
- if (!auth) return;
398
- const info = await auth.handleIncomingRedirect().catch(() => null);
399
- const pending = sessionStorage.getItem('fp-attach');
400
- if (!info?.isLoggedIn || !pending) return;
401
- sessionStorage.removeItem('fp-attach');
402
- const p = JSON.parse(pending);
403
- pick('pick-manage');
404
- subPick('pick-attach');
405
- const n = $('attach-note');
406
- n.hidden = false;
407
- n.textContent = 'signed in as ' + info.webId + ' — attaching…';
408
- const res = await auth.fetch('/api/attach', {
409
- method: 'POST', headers: { 'content-type': 'application/json' },
410
- body: JSON.stringify({ handle: p.slug, podHome: p.podHome, kind: p.kind }),
411
- }).catch(() => null);
412
- const d = res ? await res.json().catch(() => ({})) : {};
413
- if (res && res.status === 201) {
414
- n.innerHTML = 'Attached. Run this on the machine your agent lives on, then restart the agent:'
415
- + '<pre>' + d.command + '</pre>'
416
- + 'Your mail then arrives through <code>' + d.doorInbox + '</code>, filtered. Your name has not moved.';
417
- } else {
418
- n.textContent = 'attach failed: ' + (d.error || (res ? 'HTTP ' + res.status : 'no response'));
419
- }
420
- })();
421
40
  </script>
422
- <i>(cc) 4.0 By, Jeff Zucker, 2026</i>
41
+ <i style="text-align:right">(cc) 4.0 By, Jeff Zucker, 2026</i>
423
42
  </body>
424
43
  </html>
@@ -0,0 +1,50 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>FediPod — access the Fediverse and ATProto from a Solid pod</title>
7
+ <style>
8
+ :root { color-scheme: light dark; --line:#d9d4c7; --dim:#45443d; --accent:#6a8a5a; }
9
+ @media (prefers-color-scheme: dark) { :root { --dim:#c2c0b4; } }
10
+ body { font: 19px/1.6 system-ui, sans-serif; max-width: 44rem; margin: 2rem auto; padding: 0 1.1rem; }
11
+ h1 { font-size: 1.7rem; margin: 0 0 .2rem; }
12
+ h2 { font-size: 1.2rem; margin: 1.4rem 0 .4rem; }
13
+ p.lede { color: var(--dim); margin-top: 0; }
14
+ .hint { color: var(--dim); font-size: .95rem; }
15
+ code { background: rgba(120,120,120,.12); padding: .05em .35em; border-radius: 4px; }
16
+ pre { background: rgba(120,120,120,.12); padding: .7rem .9rem; border-radius: 8px; overflow-x: auto; }
17
+ ol.steps li { margin: .5rem 0; }
18
+ [hidden] { display: none !important; }
19
+ </style>
20
+ </head>
21
+ <body>
22
+ <h1>FediPod - join the fediverse from a Solid pod.</h1>
23
+ <p class="lede">Welcome to <b>FediPod</b! Here, you can get a free pod and fediverse account or link to your existing pod/account.</p>
24
+
25
+
26
+ <p>
27
+ FediPod is experimental and free to use. See the <a href="https://github.com/jeff-zucker/FediPod">FediPod repository</a> for details.
28
+ </p>
29
+
30
+ <p class="hint" id="current-version" hidden>Current FediPod: <strong id="current-version-num"></strong><br>
31
+ — update an existing install with <code id="current-version-cmd"></code></p>
32
+
33
+ <script>
34
+ // Another host deploying this front serves the same page: the command names
35
+ // that host, not fedipod.net.
36
+ if (location.origin !== 'https://fedipod.net' && location.protocol.startsWith('http')) {
37
+ document.getElementById('install-cmd').textContent =
38
+ `curl -fsSL ${location.origin}/install | sh\n\nfedipod start`;
39
+ }
40
+ fetch('/api/handle?handle=__probe__').then(r => r.json()).then(d => {
41
+ if (d.version) {
42
+ document.getElementById('current-version-num').textContent = d.version;
43
+ document.getElementById('current-version-cmd').textContent = `curl -fsSL ${location.origin}/install | sh`;
44
+ document.getElementById('current-version').hidden = false;
45
+ }
46
+ }).catch(() => {});
47
+ </script>
48
+ <i style="text-align:right">(cc) 4.0 By, Jeff Zucker, 2026</i>
49
+ </body>
50
+ </html>