fedipod 0.9.0 → 0.10.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('Restart the agent (or `fedipod up`) to republish 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
+ published at the next restart. The admin page's Gateway panel is the same
202
+ 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,24 @@ 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** panel attaches this account to a mail-filtering gateway (a
78
+ fedipod.net-style front), shows what its door has verified, and detaches it.
79
+ Give the gateway's address and the name you want there — the panel checks the
80
+ name is free as you type — and choose a pod-based name (`@you@your.pod`) or a
81
+ gateway-based name (`@you@the-gateway`); the gateway account is created
82
+ automatically either way. Attaching proves the pod with the agent's own
83
+ credential, so no password is typed anywhere. Detaching republishes your
84
+ actor with your pod's own inbox.
85
+
73
86
  ## Moving here from another server
74
87
 
75
88
  Open **Transfer an account here** on the action rail and add your old account
76
89
  as an alias, set
77
90
  **new followers** to *accepted automatically*, then trigger the move on the
78
- old server; your followers arrive by themselves. Removing an alias asks
91
+ old server (on Mastodon: Preferences Account *Move to a different
92
+ account*); your followers arrive by themselves. Removing an alias asks
79
93
  twice — servers still processing the move check it while they retry. The CSV
80
94
  files from the old server's export are imported with the CLI; see
81
95
  [CLI admin](cli.md).
package/lib/admin.mjs CHANGED
@@ -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
@@ -592,6 +594,10 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
592
594
  privateRoot: agent.readCredential?.()?.privateRoot || null,
593
595
  mode: agent.status?.().mode || null, port, home: tildify(agent.home) || null,
594
596
  update: agent.updateInfo || null,
597
+ // What this process is running, against what a restart would run:
598
+ // the checkout can move while an agent stays up.
599
+ version: AGENT_VERSION,
600
+ versionOnDisk: versionOnDisk(),
595
601
  pendingUpgrade: agent.pendingUpgrade || [],
596
602
  // The connected Bluesky account, non-secret half. `connected` is the
597
603
  // credential's word, so a config entry orphaned by a deleted
@@ -1423,6 +1429,16 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1423
1429
  const cfg = { ...agent.store.getConfig() };
1424
1430
  const g = { ...(cfg.gateway || {}) };
1425
1431
  const inboxUrl = agent.publisher?.urls?.inbox;
1432
+ // Availability is a read: answered before the lease takeover below,
1433
+ // which every real gateway change does want.
1434
+ if (body.action === 'check') {
1435
+ const front = String(body.front || '').replace(/\/+$/, '');
1436
+ try { new URL(front); } catch { return json(res, 400, { error: 'front must be a gateway origin URL' }); }
1437
+ const chk = await fetch(`${front}/api/handle?handle=${encodeURIComponent(String(body.handle || '').toLowerCase())}`,
1438
+ { headers: { accept: 'application/json' } }).then((r) => r.json()).catch(() => null);
1439
+ if (!chk) return json(res, 502, { error: `${front} did not answer its handle check` });
1440
+ return json(res, 200, { available: !!chk.available, reason: chk.reason || null });
1441
+ }
1426
1442
  await agent.requestTakeover?.();
1427
1443
  const persist = async () => {
1428
1444
  cfg.gateway = g; agent.store.setConfig(cfg);
@@ -1478,6 +1494,56 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1478
1494
  else if (target !== 'off') await agent.publisher?.publishGatewayPolicy().catch(() => {});
1479
1495
  return json(res, 200, { ok: true, mode: g.mode });
1480
1496
  }
1497
+ // Attach through a multi-user front (fedipod.net and kin): the agent
1498
+ // proves the pod with its own credential — no browser, no password —
1499
+ // and the front answers with the door and the receipt secret.
1500
+ if (body.action === 'attach') {
1501
+ const front = String(body.front || '').replace(/\/+$/, '');
1502
+ let fu;
1503
+ try { fu = new URL(front); } catch { return json(res, 400, { error: 'front must be the gateway origin, like https://fedipod.net' }); }
1504
+ if (fu.protocol !== 'https:' && !/^(localhost|127\.0\.0\.1)$|\.localhost$/.test(fu.hostname)) {
1505
+ return json(res, 400, { error: 'front must be https' });
1506
+ }
1507
+ if (agent.embedded) {
1508
+ return json(res, 400, { error: 'this identity runs inside its pod server and has no portable credential — attach from a standalone agent' });
1509
+ }
1510
+ const handle = String(body.handle || cfg.handle || '').toLowerCase().trim();
1511
+ if (!handle) return json(res, 400, { error: 'a name at the gateway is required' });
1512
+ const fronted = body.fronted === true;
1513
+ const frontActor = `${front}/u/${handle}/ap/actor`;
1514
+ if (g.frontActor && (!fronted || g.frontActor !== frontActor)) {
1515
+ 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` });
1516
+ }
1517
+ // Availability first, for a clean answer before anything is created.
1518
+ const chk = await fetch(`${front}/api/handle?handle=${encodeURIComponent(handle)}`,
1519
+ { headers: { accept: 'application/json' } }).then((r) => r.json()).catch(() => null);
1520
+ if (!chk) return json(res, 502, { error: `${front} did not answer its handle check` });
1521
+ if (!chk.available) return json(res, 409, { error: chk.reason || `the name ${handle} is taken at ${front}` });
1522
+ const attach = await agent.remote.session.fetch(`${front}/api/attach`, {
1523
+ method: 'POST', headers: { 'content-type': 'application/json' },
1524
+ body: JSON.stringify({ handle, podHome: agent.urls.home, kind: cfg.kind || 'person', fronted }),
1525
+ }).catch(() => null);
1526
+ if (!attach) return json(res, 502, { error: `${front} did not answer the attach` });
1527
+ const d = await attach.json().catch(() => ({}));
1528
+ if (attach.status !== 201) {
1529
+ return json(res, attach.status >= 400 && attach.status < 500 ? attach.status : 502,
1530
+ { error: d.error || `attach failed (HTTP ${attach.status})` });
1531
+ }
1532
+ g.url = String(d.doorInbox || `${front}/u/${handle}/ap/inbox/`);
1533
+ if (d.hmacSecret) g.hmacSecret = String(d.hmacSecret);
1534
+ if (fronted) g.frontActor = String(d.frontActor || frontActor);
1535
+ if (!g.mode || g.mode === 'off') g.mode = 'shadow';
1536
+ await persist();
1537
+ // Inbox-only applies live: the actor republishes advertising the
1538
+ // door. A front carries new ids, which are wired at startup — that
1539
+ // attach persists and owes a restart.
1540
+ if (!fronted) {
1541
+ await agent.publisher?.publishProfile();
1542
+ await agent.publisher?.publishGatewayPolicy?.().catch(() => {});
1543
+ }
1544
+ return json(res, 200, { ok: true, mode: g.mode, url: g.url,
1545
+ ...(fronted ? { frontActor: g.frontActor, address: d.address || null, restart: true } : {}) });
1546
+ }
1481
1547
  if (body.action === 'forget') {
1482
1548
  const wasLocked = g.mode === 'locked';
1483
1549
  delete cfg.gateway; agent.store.setConfig(cfg);
@@ -1487,7 +1553,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1487
1553
  await agent.publisher?.publishProfile(); // re-advertise the pod inbox
1488
1554
  return json(res, 200, { ok: true, mode: 'off', forgotten: true });
1489
1555
  }
1490
- return json(res, 400, { error: 'action must be configure, mode or forget' });
1556
+ return json(res, 400, { error: 'action must be configure, mode, check, attach or forget' });
1491
1557
  }
1492
1558
  // Symmetrical with /block, and open for the same reason: a block made
1493
1559
  // by mistake is worth undoing before federation is even configured.
@@ -1534,14 +1600,17 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1534
1600
  return { handler, masto, c2s, streaming };
1535
1601
  }
1536
1602
 
1537
- export function startAdmin({ port, gateToken, agent, log = console.log, handle = null, tls = null }) {
1603
+ export function startAdmin({ port, gateToken, agent, log = console.log, handle = null, tls = null,
1604
+ // Injectable, so a test can put a checkout ahead of the running process
1605
+ // without editing the package.json of the machine running the test.
1606
+ versionOnDisk = () => localVersion(projectRoot) }) {
1538
1607
  const gate = makeGate(gateToken);
1539
1608
  // Live, so the named origin appears the moment connect() reads the handle
1540
1609
  // out of pod state — including for the OAuth redirect check in MastoApi.
1541
1610
  // The https listener's port joins the authority set: same names, second port.
1542
1611
  const allowed = new Authorities(port, handle);
1543
1612
  agent.authorities = allowed;
1544
- const { handler, streaming } = buildAdminSurface({ agent, gate, allowed, log, port, handle });
1613
+ const { handler, streaming } = buildAdminSurface({ agent, gate, allowed, log, port, handle, versionOnDisk });
1545
1614
 
1546
1615
  // Loopback both ways: the canonical URL is https://localhost:<port>/, and
1547
1616
  // "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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "0.9.0",
3
+ "version": "0.10.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
@@ -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 });
@@ -141,7 +141,7 @@ 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
145
  if (config.quiescedAt) rows.push(['parked since', config.quiescedAt]);
146
146
  if (config.movedTo) rows.push(['moved to', config.movedTo]);
147
147
  // A person gates followers here; a group's gate is the joins control on its
@@ -190,8 +190,13 @@ function render() {
190
190
  dd.append(UPDATE_CTL);
191
191
  UPDATE_CTL.hidden = false;
192
192
  const u = config.update;
193
+ // The version this agent is running, never the one sitting in the
194
+ // checkout — saying otherwise would name a version nobody is serving.
195
+ const running = config.version || u?.current || null;
193
196
  const words = [];
194
- if (u) words.push(u.available ? `FediPod ${u.current} — ${u.latest} available` : `FediPod ${u.current}`);
197
+ if (running) words.push(u?.available ? `FediPod ${running} — ${u.latest} available` : `FediPod ${running}`);
198
+ if (config.versionOnDisk && running && config.versionOnDisk !== running)
199
+ words.push(`${config.versionOnDisk} is on disk — restart to run it`);
195
200
  if (config.pendingUpgrade?.length) words.push('older data layout — run `fedipod upgrade` in a terminal');
196
201
  UPDATE_WORD.textContent = words.join('; ');
197
202
  UPDATE_GO.hidden = !u?.available;
@@ -232,6 +237,7 @@ function render() {
232
237
  renderAliases();
233
238
  renderOthers();
234
239
  renderInbox();
240
+ renderGateway();
235
241
  if (config.kind === 'group') {
236
242
  // Its lists have no bound, so this page scrolls — see body.group in the CSS.
237
243
  document.body.classList.add('group');
@@ -931,9 +937,62 @@ async function renderInbox() {
931
937
  panel.hidden = false;
932
938
  }
933
939
 
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.
940
+ // The gateway panel: attach through a multi-user front with this agent's own
941
+ // credential, see what the door has verified, detach back to the pod inbox.
942
+ let gwTimer = null;
943
+ async function renderGateway() {
944
+ const { status, json: g } = await api('/gateway');
945
+ if (status !== 200 || !g) return; // no answer, no surface
946
+ $('pane-gateway').hidden = false;
947
+ if (g.configured) {
948
+ const host = (() => { try { return new URL(g.url).host; } catch { return g.url; } })();
949
+ const st = g.stats || {};
950
+ $('gateway-summary').textContent = `Attached to ${host} (mode ${g.mode})`
951
+ + (g.frontActor ? `, publishing as ${g.frontActor}` : '')
952
+ + ` — ${st.verified || 0} deliveries verified, ${st.unverified || 0} unverified.`;
953
+ $('gateway-attach-form').hidden = true;
954
+ $('gateway-attached').hidden = false;
955
+ } else {
956
+ $('gateway-summary').textContent = 'Mail arrives directly at your pod\'s own inbox.';
957
+ $('gateway-attach-form').hidden = false;
958
+ $('gateway-attached').hidden = true;
959
+ if (!$('gw-name').value && config?.handle) $('gw-name').value = config.handle;
960
+ }
961
+ }
962
+ // Live availability, asked through the agent (the front answers it without CORS).
963
+ function gwCheck() {
964
+ clearTimeout(gwTimer);
965
+ const front = $('gw-front').value.trim().replace(/\/+$/, '');
966
+ const name = $('gw-name').value.trim().toLowerCase();
967
+ $('gw-name-msg').textContent = ''; $('gw-name-msg').className = 'hint';
968
+ if (!front || !name) return;
969
+ gwTimer = setTimeout(async () => {
970
+ const { status, json } = await postJson('/gateway', { action: 'check', front, handle: name });
971
+ if (status !== 200 || !json) return;
972
+ $('gw-name-msg').textContent = json.available
973
+ ? `${name} is free at ${front.replace(/^https?:\/\//, '')}`
974
+ : (json.reason || 'that name is taken');
975
+ $('gw-name-msg').className = json.available ? 'hint' : 'warn';
976
+ }, 300);
977
+ }
978
+ $('gw-front').addEventListener('input', gwCheck);
979
+ $('gw-name').addEventListener('input', gwCheck);
980
+ $('gw-attach').onclick = async () => {
981
+ const front = $('gw-front').value.trim().replace(/\/+$/, '');
982
+ const fronted = $('gw-shape').value === 'front';
983
+ $('gw-attach').disabled = true;
984
+ const r = await write('/gateway',
985
+ { action: 'attach', front, handle: $('gw-name').value.trim().toLowerCase(), fronted },
986
+ fronted ? 'attached — restart the agent to publish under the gateway name'
987
+ : 'attached — your mail now arrives through the gateway, filtered');
988
+ $('gw-attach').disabled = false;
989
+ if (r) renderGateway();
990
+ };
991
+ $('gw-detach').onclick = async () => {
992
+ const r = await write('/gateway', { action: 'forget' },
993
+ 'detached — the actor was republished advertising your pod\'s own inbox');
994
+ if (r) renderGateway();
995
+ };
937
996
 
938
997
  $('inbox-keep').addEventListener('click', () => {
939
998
  dismissed = true;
@@ -314,9 +314,36 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
314
314
  </span>
315
315
  </section>
316
316
 
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. -->
317
+ <!-- The inbox gateway: a mail filter for an account that stays yours. -->
318
+ <section id="pane-gateway" hidden>
319
+ <h2><span class="hlabel">Gateway</span></h2>
320
+ <p id="gateway-summary"></p>
321
+ <div id="gateway-attach-form" hidden>
322
+ <p class="hint">A gateway filters your fediverse mail before it reaches your pod: your
323
+ actor advertises the gateway's door as its inbox, each delivery is verified there,
324
+ spam is dropped, and the rest lands in your pod as before. Attaching proves the pod
325
+ with this agent's own credential — no password leaves this machine.</p>
326
+ <p>
327
+ <label for="gw-front">Gateway</label>
328
+ <input type="url" id="gw-front" placeholder="https://fedipod.net" autocomplete="off">
329
+ <label for="gw-name">Your name there</label>
330
+ <input type="text" id="gw-name" autocomplete="off" autocapitalize="off" spellcheck="false">
331
+ </p>
332
+ <p id="gw-name-msg" class="hint"></p>
333
+ <p>
334
+ <select id="gw-shape" aria-label="Name shape" title=" Which address the fediverse sees — the pod-based name keeps everything on your pod; a gateway-based name survives changing pods">
335
+ <option value="pod">keep my pod-based name</option>
336
+ <option value="front">use a gateway-based name</option>
337
+ </select>
338
+ <button id="gw-attach" class="primary" title=" Create the gateway account and point your mail through its door">Attach</button>
339
+ </p>
340
+ </div>
341
+ <div id="gateway-attached" hidden>
342
+ <p>
343
+ <button id="gw-detach" class="inline danger" title=" Republish the actor with your pod's own inbox and forget the gateway">Detach</button>
344
+ </p>
345
+ </div>
346
+ </section>
320
347
 
321
348
  <section id="pane-group" hidden>
322
349
  <!-- Both queues are consequences of a moderation setting: with the setting off
@@ -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>