fedipod 0.10.0 → 0.13.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
@@ -1518,7 +1518,7 @@ if (cmd === 'up') {
1518
1518
  if (res.status >= 400) { console.error(body.error || `HTTP ${res.status}`); process.exit(1); }
1519
1519
  if (body.frontActor) {
1520
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.');
1521
+ console.log('The agent is restarting itself to publish under the front.');
1522
1522
  } else {
1523
1523
  console.log(`attached — your mail now arrives through ${body.url}, filtered; your name has not moved.`);
1524
1524
  console.log('Starting in shadow: the door filters, and the agent measures how much');
package/cli.md CHANGED
@@ -198,8 +198,8 @@ fedipod gateway --detach
198
198
  `--attach` asks the gateway itself: the agent proves the pod with its own
199
199
  credential, and the gateway answers with the door and the receipt secret.
200
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.
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.
203
203
  Attaching points your actor's advertised inbox at a gateway's door, so
204
204
  deliveries are verified and de-junked before they reach your pod; your name,
205
205
  key and data stay on your pod. The URL and secret come from the gateway's
package/gui.md CHANGED
@@ -74,14 +74,18 @@ accepting automatically.
74
74
 
75
75
  ## The gateway
76
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.
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.
85
89
 
86
90
  ## Moving here from another server
87
91
 
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';
@@ -490,7 +490,10 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
490
490
  // the handle, and the pod host its actor URL sits on.
491
491
  let address = null;
492
492
  if (live?.handle && live?.actor) {
493
- 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 */ }
494
497
  }
495
498
  return {
496
499
  name, port, current,
@@ -568,6 +571,11 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
568
571
  if (!cfg) return json(res, 409, { error: 'agent not configured — set it up at /admin/setup/' });
569
572
  const urls = agent.urls || agent.publisher?.urls || null;
570
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);
571
579
  return json(res, 200, {
572
580
  // permanent
573
581
  handle: cfg.handle, remotePod: cfg.remotePod, issuer: cfg.issuer,
@@ -578,7 +586,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
578
586
  // same way every other id is — computed here rather than in the page,
579
587
  // which has no business knowing how they are made.
580
588
  accountId: urls?.actor ? agent.store.idFor(urls.actor) : null,
581
- address: wfHost ? `@${cfg.handle}@${wfHost}` : null,
589
+ address,
582
590
  // editable
583
591
  name: cfg.name || null, summary: cfg.summary || null, icon: cfg.icon || null,
584
592
  image: cfg.image || null, fields: cfg.fields || [],
@@ -1445,6 +1453,31 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1445
1453
  if (agent.publisher) agent.publisher.config.gateway = g;
1446
1454
  await agent.store.flush();
1447
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);
1448
1481
  if (body.action === 'configure') {
1449
1482
  if (!/^https:\/\/\S+$/.test(String(body.url || ''))) return json(res, 400, { error: 'gateway url must be https' });
1450
1483
  if (!/^https?:\/\/\S+$/.test(String(body.webId || ''))) return json(res, 400, { error: 'gateway webId must be a URL' });
@@ -1507,18 +1540,29 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1507
1540
  if (agent.embedded) {
1508
1541
  return json(res, 400, { error: 'this identity runs inside its pod server and has no portable credential — attach from a standalone agent' });
1509
1542
  }
1510
- const handle = String(body.handle || cfg.handle || '').toLowerCase().trim();
1543
+ const named = !!String(body.handle || '').trim();
1544
+ let handle = String(body.handle || cfg.handle || '').toLowerCase().trim();
1511
1545
  if (!handle) return json(res, 400, { error: 'a name at the gateway is required' });
1512
1546
  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
1547
  // Availability first, for a clean answer before anything is created.
1518
- const chk = await fetch(`${front}/api/handle?handle=${encodeURIComponent(handle)}`,
1548
+ const avail = async (h) => fetch(`${front}/api/handle?handle=${encodeURIComponent(h)}`,
1519
1549
  { headers: { accept: 'application/json' } }).then((r) => r.json()).catch(() => null);
1550
+ let chk = await avail(handle);
1520
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
+ }
1521
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
+ }
1522
1566
  const attach = await agent.remote.session.fetch(`${front}/api/attach`, {
1523
1567
  method: 'POST', headers: { 'content-type': 'application/json' },
1524
1568
  body: JSON.stringify({ handle, podHome: agent.urls.home, kind: cfg.kind || 'person', fronted }),
@@ -1535,21 +1579,35 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1535
1579
  if (!g.mode || g.mode === 'off') g.mode = 'shadow';
1536
1580
  await persist();
1537
1581
  // 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.
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.
1540
1585
  if (!fronted) {
1541
1586
  await agent.publisher?.publishProfile();
1542
1587
  await agent.publisher?.publishGatewayPolicy?.().catch(() => {});
1588
+ return json(res, 200, { ok: true, mode: g.mode, url: g.url });
1543
1589
  }
1544
- return json(res, 200, { ok: true, mode: g.mode, url: g.url,
1545
- ...(fronted ? { frontActor: g.frontActor, address: d.address || null, restart: true } : {}) });
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;
1546
1595
  }
1547
1596
  if (body.action === 'forget') {
1548
1597
  const wasLocked = g.mode === 'locked';
1598
+ const wasFronted = !!g.frontActor;
1549
1599
  delete cfg.gateway; agent.store.setConfig(cfg);
1550
1600
  if (agent.publisher) agent.publisher.config.gateway = undefined;
1551
1601
  await agent.store.flush();
1552
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
+ }
1553
1611
  await agent.publisher?.publishProfile(); // re-advertise the pod inbox
1554
1612
  return json(res, 200, { ok: true, mode: 'off', forgotten: true });
1555
1613
  }
@@ -130,6 +130,8 @@ function identFor(rec, policy = null) {
130
130
  // host the front's own host, e.g. "fedipod.net" (for WebFinger subjects)
131
131
  // frontOrigin "https://fedipod.net"
132
132
  // lookup(handle) -> record | null the directory
133
+ // listDirectory() -> { handle: record } every row, for the admin roster
134
+ // removeDirectory(handle) -> boolean drop a row; false when a seeded row remains
133
135
  // podPut(url, body, ct) -> boolean append to a user's pod (per-user cred inside)
134
136
  // podGet(url) -> Response read a user's pod (public reads; plain fetch is fine)
135
137
  export async function routeFront(request, ctx) {
@@ -155,6 +157,51 @@ export async function routeFront(request, ctx) {
155
157
  return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: ctx.runPage };
156
158
  }
157
159
 
160
+ // The admin page: the host reading who has accounts here. The page signs in
161
+ // and calls the roster API below; a deploy with no admin supplies no page.
162
+ if (pathname === '/admin') {
163
+ if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: {}, body: '' };
164
+ if (!ctx.adminPage) return notFound();
165
+ return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: ctx.adminPage };
166
+ }
167
+
168
+ // The roster: every directory row, secrets stripped, for the host's own
169
+ // eyes. The reader proves themself the way attach proves a pod — a
170
+ // Solid-OIDC token — and must be the WebID the deploy names as admin.
171
+ if (pathname === '/api/roster') {
172
+ if (!ctx.listDirectory || !ctx.adminWebId) return j(501, { error: 'this front has no roster to offer' });
173
+ const webid = await verifyPodToken(request, pathname, ctx.verifier);
174
+ if (!webid) return j(401, { error: 'a Solid-OIDC token is required' });
175
+ if (webid !== ctx.adminWebId) return j(403, { error: 'that WebID is not the admin of this front' });
176
+ const rows = await ctx.listDirectory();
177
+ const accounts = Object.values(rows)
178
+ .map((r) => ({
179
+ handle: r.handle, kind: r.kind || 'person', fronted: !r.inboxOnly,
180
+ podHome: r.podHome, webId: r.webId || null, actorUrl: r.actorUrl,
181
+ address: `@${r.handle}@${ctx.host}`,
182
+ }))
183
+ .sort((a, b) => a.handle.localeCompare(b.handle));
184
+ return j(200, { host: ctx.host, accounts });
185
+ }
186
+
187
+ // Revoke: the admin removes an account's row, so the server stops answering
188
+ // for the name. Nothing on the user's pod is touched. Attach-created rows go
189
+ // for good; a row seeded in the deploy's environment can only be removed there.
190
+ if (pathname === '/api/revoke' && request.method === 'POST') {
191
+ if (!ctx.removeDirectory || !ctx.adminWebId) return j(501, { error: 'this front cannot revoke accounts' });
192
+ const webid = await verifyPodToken(request, pathname, ctx.verifier);
193
+ if (!webid) return j(401, { error: 'a Solid-OIDC token is required' });
194
+ if (webid !== ctx.adminWebId) return j(403, { error: 'that WebID is not the admin of this front' });
195
+ let body;
196
+ try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
197
+ const handle = String(body.handle || '').toLowerCase();
198
+ if (!(await ctx.lookup(handle))) return j(404, { error: 'no such account' });
199
+ const removed = (await ctx.removeDirectory(handle)) === true;
200
+ return j(200, removed ? { handle, removed }
201
+ : { handle, removed: false,
202
+ reason: 'this row is seeded in the deploy environment (FEDIPOD_DIRECTORY_JSON) — remove it there and redeploy' });
203
+ }
204
+
158
205
  // Live handle check for the page: valid shape AND not already in the
159
206
  // directory. Also tells the page whether this host offers pods.
160
207
  if (pathname === '/api/handle') {
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.10.0",
3
+ "version": "0.13.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 {
@@ -539,7 +539,9 @@ export class Agent {
539
539
  this.schedTimer.unref();
540
540
  // A CSV import interrupted by a restart or a handoff picks back up here.
541
541
  this.importer?.resume();
542
- 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}`);
543
545
  if (this.renamed) {
544
546
  // The display name lives in the actor document, so a rename only
545
547
  // reaches other servers once that is republished.
@@ -142,6 +142,7 @@ function render() {
142
142
  .replace(/\/$/, '')],
143
143
  ];
144
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,6 +186,11 @@ 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);
@@ -237,7 +243,6 @@ function render() {
237
243
  renderAliases();
238
244
  renderOthers();
239
245
  renderInbox();
240
- renderGateway();
241
246
  if (config.kind === 'group') {
242
247
  // Its lists have no bound, so this page scrolls — see body.group in the CSS.
243
248
  document.body.classList.add('group');
@@ -937,35 +942,62 @@ async function renderInbox() {
937
942
  panel.hidden = false;
938
943
  }
939
944
 
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() {
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() {
944
954
  const { status, json: g } = await api('/gateway');
945
- if (status !== 200 || !g) return; // no answer, no surface
946
- $('pane-gateway').hidden = false;
955
+ if (status !== 200 || !g) return;
956
+ gwState = g;
957
+ GATEWAY_CTL.hidden = false;
947
958
  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;
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;
955
965
  } 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;
966
+ GATEWAY_WORD.textContent = '';
967
+ GW_OPEN_ATTACH.hidden = false;
968
+ GW_OPEN_DETACH.hidden = true;
960
969
  }
961
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
+ });
962
993
  // Live availability, asked through the agent (the front answers it without CORS).
994
+ let gwTimer = null;
963
995
  function gwCheck() {
964
996
  clearTimeout(gwTimer);
965
997
  const front = $('gw-front').value.trim().replace(/\/+$/, '');
966
998
  const name = $('gw-name').value.trim().toLowerCase();
967
999
  $('gw-name-msg').textContent = ''; $('gw-name-msg').className = 'hint';
968
- if (!front || !name) return;
1000
+ if (gwShape() !== 'front' || !front || !name) return;
969
1001
  gwTimer = setTimeout(async () => {
970
1002
  const { status, json } = await postJson('/gateway', { action: 'check', front, handle: name });
971
1003
  if (status !== 200 || !json) return;
@@ -975,24 +1007,30 @@ function gwCheck() {
975
1007
  $('gw-name-msg').className = json.available ? 'hint' : 'warn';
976
1008
  }, 300);
977
1009
  }
978
- $('gw-front').addEventListener('input', gwCheck);
979
- $('gw-name').addEventListener('input', gwCheck);
980
- $('gw-attach').onclick = async () => {
1010
+ $('gw-front').addEventListener('input', () => { gwPreviews(); gwCheck(); });
1011
+ $('gw-name').addEventListener('input', () => { gwPreviews(); gwCheck(); });
1012
+ $('gw-attach').addEventListener('click', async () => {
981
1013
  const front = $('gw-front').value.trim().replace(/\/+$/, '');
982
- const fronted = $('gw-shape').value === 'front';
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; }
983
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.
984
1020
  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'
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'
987
1023
  : 'attached — your mail now arrives through the gateway, filtered');
988
1024
  $('gw-attach').disabled = false;
989
- if (r) renderGateway();
990
- };
991
- $('gw-detach').onclick = async () => {
1025
+ if (r) { closePanels(); refreshGateway(); }
1026
+ });
1027
+ $('gw-detach').addEventListener('click', async () => {
1028
+ const fronted = !!gwState?.frontActor;
992
1029
  const r = await write('/gateway', { action: 'forget' },
993
- 'detached — the actor was republished advertising your pod\'s own inbox');
994
- if (r) renderGateway();
995
- };
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
+ });
996
1034
 
997
1035
  $('inbox-keep').addEventListener('click', () => {
998
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,36 +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: 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>
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>
347
325
 
348
326
  <section id="pane-group" hidden>
349
327
  <!-- Both queues are consequences of a moderation setting: with the setting off
@@ -448,6 +426,45 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
448
426
  <!-- Every consequence is written here rather than assembled in script: the
449
427
  page asking "are you sure" has to say what it is sure about, and this is
450
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>
451
468
  <form id="confirm-form" hidden>
452
469
  <div id="warn-rotate-key" class="warn" hidden>
453
470
  <p>A new keypair replaces the one in this home and the actor is republished so other
@@ -0,0 +1,129 @@
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 — accounts on this server</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
+ p.lede { color: var(--dim); margin-top: 0; }
13
+ label { display: block; font-weight: 600; margin: .8rem 0 .2rem; }
14
+ input[type=url] { width: 100%; padding: .5rem .6rem; font: inherit;
15
+ border: 1px solid var(--line); border-radius: 8px; box-sizing: border-box; }
16
+ .hint { color: var(--dim); font-size: .95rem; }
17
+ .primary { background: var(--accent); color: #fff; border: none; border-radius: 9px;
18
+ padding: .6rem 1.1rem; font: inherit; cursor: pointer; }
19
+ .primary[disabled] { opacity: .5; cursor: not-allowed; }
20
+ button { font: inherit; cursor: pointer; }
21
+ [hidden] { display: none !important; }
22
+ table { border-collapse: collapse; width: 100%; margin: 1.2rem 0; }
23
+ th, td { text-align: left; padding: .5rem .7rem; border-bottom: 1px solid var(--line); }
24
+ th { font-weight: 600; }
25
+ </style>
26
+ </head>
27
+ <body>
28
+ <h1>Accounts on this server</h1>
29
+ <p class="lede">Every name this server answers for: accounts whose identity lives here, and
30
+ accounts that only use it as their gateway. Sign in as the server's admin to see them.</p>
31
+
32
+ <form id="roster-form">
33
+ <p class="field">
34
+ <label for="roster-issuer">Your identity provider</label>
35
+ <input type="url" id="roster-issuer" autocomplete="off" value="https://solidcommunity.net">
36
+ </p>
37
+ <p class="actions">
38
+ <button type="submit" id="roster-signin" class="primary">Sign in &amp; list accounts</button>
39
+ </p>
40
+ <p class="hint" id="roster-note" hidden></p>
41
+ </form>
42
+
43
+ <table id="roster-table" hidden>
44
+ <thead>
45
+ <tr><th>Account</th><th>Kind</th><th>Identity</th><th>Pod</th><th>Remove</th></tr>
46
+ </thead>
47
+ <tbody id="roster-rows"></tbody>
48
+ </table>
49
+
50
+ <script src="/solid-client-authn.bundle.js"></script>
51
+ <script>
52
+ const $ = (id) => document.getElementById(id);
53
+ const note = (text) => { const n = $('roster-note'); n.hidden = !text; n.textContent = text || ''; };
54
+
55
+ $('roster-issuer').addEventListener('input', () => {
56
+ $('roster-signin').disabled = !/^https?:\/\/\S+/.test($('roster-issuer').value.trim());
57
+ });
58
+
59
+ $('roster-form').addEventListener('submit', (ev) => {
60
+ ev.preventDefault();
61
+ const auth = window.solidClientAuthentication;
62
+ if (!auth) { note('the sign-in library did not load — reload and try again'); return; }
63
+ auth.login({
64
+ oidcIssuer: $('roster-issuer').value.trim(),
65
+ redirectUrl: location.origin + '/admin',
66
+ clientName: 'FediPod admin',
67
+ }).catch((e) => note('sign-in failed to start: ' + e.message));
68
+ });
69
+
70
+ // Back from the identity provider: read the roster with the proven login.
71
+ (async () => {
72
+ const auth = window.solidClientAuthentication;
73
+ if (!auth) return;
74
+ const info = await auth.handleIncomingRedirect().catch(() => null);
75
+ if (!info?.isLoggedIn) return;
76
+
77
+ // Signed fetches build a DPoP proof from the URL, so it must be absolute.
78
+ const load = async () => {
79
+ let res;
80
+ try { res = await auth.fetch(location.origin + '/api/roster'); }
81
+ catch (e) { note('the roster request failed: ' + e.message); return; }
82
+ const d = await res.json().catch(() => ({}));
83
+ if (res.status === 403) { note('signed in as ' + info.webId + ', which is not this server’s admin'); return; }
84
+ if (res.status === 501) { note('this server names no admin — set FEDIPOD_ADMIN_WEBID and redeploy'); return; }
85
+ if (res.status !== 200) { note('roster unavailable: ' + (d.error || 'HTTP ' + res.status)); return; }
86
+ const rows = $('roster-rows');
87
+ rows.textContent = '';
88
+ for (const a of d.accounts || []) {
89
+ const tr = document.createElement('tr');
90
+ const cell = (child) => { const td = document.createElement('td'); td.append(child); tr.append(td); };
91
+ cell(a.address);
92
+ cell(a.kind);
93
+ cell(a.fronted ? 'lives here' : 'gateway only');
94
+ const pod = document.createElement('a');
95
+ pod.href = a.podHome; pod.textContent = new URL(a.podHome).host;
96
+ cell(pod);
97
+ const rm = document.createElement('button');
98
+ rm.type = 'button'; rm.textContent = 'Remove';
99
+ rm.onclick = () => revoke(a);
100
+ cell(rm);
101
+ rows.append(tr);
102
+ }
103
+ $('roster-table').hidden = false;
104
+ note((d.accounts || []).length + ' account(s) on ' + d.host);
105
+ };
106
+
107
+ const revoke = async (a) => {
108
+ if (!confirm('Remove ' + a.address + ' from this server? The name stops resolving here; nothing on their pod is touched.')) return;
109
+ let res;
110
+ try {
111
+ res = await auth.fetch(location.origin + '/api/revoke', {
112
+ method: 'POST', headers: { 'content-type': 'application/json' },
113
+ body: JSON.stringify({ handle: a.handle }),
114
+ });
115
+ } catch (e) { note('the remove request failed: ' + e.message); return; }
116
+ const d = await res.json().catch(() => ({}));
117
+ if (res.status !== 200) { note('remove refused: ' + (d.error || 'HTTP ' + res.status)); return; }
118
+ if (!d.removed) { note(a.address + ' stays: ' + d.reason); return; }
119
+ await load();
120
+ note('removed ' + a.address);
121
+ };
122
+
123
+ note('signed in as ' + info.webId + ' — reading the roster…');
124
+ await load();
125
+ })();
126
+ </script>
127
+ <i>(cc) 4.0 By, Jeff Zucker, 2026</i>
128
+ </body>
129
+ </html>
@@ -114,7 +114,8 @@ you to keep running. Sign in with your pod to prove it is yours.</p>
114
114
  const n = $('run-note');
115
115
  n.hidden = false;
116
116
  n.textContent = 'signed in as ' + info.webId + ' — asking the server…';
117
- const res = await auth.fetch('/api/agent', {
117
+ // The signed fetch builds a DPoP proof from the URL, so it must be absolute.
118
+ const res = await auth.fetch(location.origin + '/api/agent', {
118
119
  method: 'POST', headers: { 'content-type': 'application/json' },
119
120
  body: JSON.stringify({ action: p.action, podBase: p.podBase }),
120
121
  }).catch(() => null);