fedipod 1.26.1 → 1.27.1

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.
@@ -31,7 +31,7 @@ export { pollParams } from './body.mjs';
31
31
  export { attachmentType, extensionFor } from './media.mjs';
32
32
 
33
33
  export class MastoApi {
34
- constructor({ agent, log = console.log, allowed = null, scheme = null, embedded = false,
34
+ constructor({ agent, log = console.log, allowed = null, scheme = null, embedded = false, throughDoor = null,
35
35
  mount = '', streaming = true, webPush = true, scheduling = true }) {
36
36
  this.agent = agent;
37
37
  // The path this identity's surface answers under, when it shares its origin
@@ -45,6 +45,9 @@ export class MastoApi {
45
45
  this.embedded = embedded;
46
46
  this.log = log;
47
47
  this.allowed = allowed; // authorities a redirect_uri may name
48
+ // Whether a request already passed the owner's door (its key or cookie):
49
+ // an owner who opened the management page is the owner at the sign-in too.
50
+ this.throughDoor = throughDoor;
48
51
  // The scheme this identity is reached on, when the socket cannot say —
49
52
  // a server behind a TLS proxy terminates cleartext and still is https.
50
53
  this.scheme = scheme;
@@ -42,6 +42,42 @@ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c =>
42
42
  const parseRedirects = (v) => (Array.isArray(v) ? v : String(v || '').split(/\s+/))
43
43
  .map(s => s.trim()).filter(Boolean);
44
44
 
45
+ // The sign-in page for an account whose owner signs in at their pod: no
46
+ // password of ours exists, and none is asked for. The page carries the
47
+ // client's request in its address, signs the owner in at the pod with the
48
+ // fediverse-account library, and proves that sign-in back to /oauth/authorize.
49
+ function sendPodSigninPage(res, client, webId, mount = '') {
50
+ let asking = '';
51
+ if (client && (client.name || client.redirect)) {
52
+ let where = '';
53
+ try { where = client.redirect ? new URL(client.redirect).host : ''; } catch { /* oob or blank */ }
54
+ const who = client.name ? escapeHtml(client.name) : (where ? escapeHtml(where) : 'A client');
55
+ asking = `<p><strong>${who}</strong> is asking to use your account`
56
+ + `${where ? `, and will be sent back to <code>${escapeHtml(where)}</code>` : ''}.</p>`;
57
+ }
58
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' });
59
+ res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
60
+ <title>Sign in with your pod</title>
61
+ <style>:root{color-scheme:light dark;--heading:#1a4f8a;--btn:#3d6b35;--line:#9a9a9a}
62
+ body{font:20px Arial,Helvetica,sans-serif;max-width:28rem;margin:12vh auto;padding:0 1rem}
63
+ h1{color:var(--heading);font-size:1.5rem}
64
+ code{word-break:break-all;background:rgba(120,120,120,.12);padding:.05em .35em;border-radius:4px}
65
+ button{font:inherit;padding:.6rem 1.1rem;background:var(--btn);color:#fff;border:none;border-radius:9px;cursor:pointer}
66
+ button[hidden]{display:none}
67
+ #signin-status{min-height:1.5em;padding:.6rem .8rem;border:1px solid var(--line);border-radius:8px;margin-top:1rem}
68
+ @media (prefers-color-scheme:dark){:root{--heading:#7fb3e8;--btn:#5a8a4f}}</style></head><body>
69
+ <main>
70
+ <h1>Sign in with your pod</h1>
71
+ ${asking}
72
+ <p>This account belongs to <a id="webid" href="${escapeHtml(webId)}">${escapeHtml(webId)}</a>. Sign in at that pod to allow it.</p>
73
+ <p><button type="button" id="signin" hidden>Sign in with your pod</button></p>
74
+ <p id="signin-status" role="status" aria-live="polite"></p>
75
+ </main>
76
+ <script type="module" src="${escapeHtml(mount)}/oauth/session/signin.mjs"></script>
77
+ </body></html>`);
78
+ return true;
79
+ }
80
+
45
81
  function sendLoginForm(res, params, error = '', client = null, status = null, headers = {}) {
46
82
  const hidden = [...params.entries()].filter(([k]) => k !== 'password')
47
83
  .map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`).join('\n');
@@ -378,6 +414,21 @@ export async function handle(api, ctx) {
378
414
  let body = null;
379
415
  if (req.method === 'POST') { body = await readBody(req); params = new URLSearchParams(body); }
380
416
  const redirect = params.get('redirect_uri') || '';
417
+ // The owner's pod sign-in, proved: a Solid-OIDC token with its DPoP proof,
418
+ // from the WebID this account belongs to. It stands in for the password
419
+ // everywhere below.
420
+ let proved = false;
421
+ if (req.method === 'POST' && req.headers.authorization && req.headers.dpop && api.podAuth) {
422
+ const proof = await api.podAuth(req, pathname);
423
+ if (!proof.ok) return send(proof.status || 401, { error: proof.error });
424
+ proved = true;
425
+ }
426
+ const ownerWebId = api.ownerWebId?.() || null;
427
+ // The way back from the pod's login: no client named, only the pod's
428
+ // code. The page finishes the sign-in and returns to the client's request.
429
+ if (req.method === 'GET' && !params.get('client_id') && params.get('code') && params.get('state') && ownerWebId) {
430
+ return sendPodSigninPage(res, null, ownerWebId, api.mount || '');
431
+ }
381
432
  const app = api.findApp(params.get('client_id') || '');
382
433
  // A client that published its own metadata document needs no
383
434
  // registration here: the document is its name and says where it may be
@@ -419,14 +470,16 @@ export async function handle(api, ctx) {
419
470
  // port is the owner" is a statement about the machine rather than
420
471
  // about a web page. Anything pointing elsewhere has to be approved by
421
472
  // somebody who knows the password.
422
- if (external && !api.store.getConfig()?.uiPassword && !api.redirectAllowed(redirect)) {
473
+ const client = { name: app?.name || doc?.name || null, redirect, scope: params.get('scope') || 'read' };
474
+ if (external && !proved && !api.store.getConfig()?.uiPassword && !api.redirectAllowed(redirect)) {
475
+ // With a pod to sign in at, the owner approves the client there.
476
+ if (req.method === 'GET' && ownerWebId) return sendPodSigninPage(res, client, ownerWebId, api.mount || '');
423
477
  api.log(`authorize refused: no UI password, and "${redirect}" is not an address of this agent`);
424
478
  return send(403, {
425
479
  error: 'this client asks to be sent somewhere other than this agent, and no password is '
426
480
  + 'set to approve that with. Run `fedipod passwd` and try again.',
427
481
  });
428
482
  }
429
- const client = { name: app?.name || doc?.name || null, redirect, scope: params.get('scope') || 'read' };
430
483
  if (app) {
431
484
  if (!app.redirectUris.includes(redirect)) {
432
485
  api.log(`authorize refused: redirect_uri "${redirect}" not registered for ${app.clientId}`);
@@ -448,7 +501,7 @@ export async function handle(api, ctx) {
448
501
  api.log(`authorize refused: redirect_uri "${redirect}" is not this agent`);
449
502
  return send(400, { error: 'redirect_uri must be an address of this agent' });
450
503
  }
451
- if (req.method === 'POST') {
504
+ if (req.method === 'POST' && !proved) {
452
505
  if (api.rateLimited()) {
453
506
  api.log('authorize rate limited');
454
507
  // 429, not 401: a client that reads this as a wrong password will
@@ -460,11 +513,13 @@ export async function handle(api, ctx) {
460
513
  if (!pw || !checkPassword(pw, body.password || '')) {
461
514
  return sendLoginForm(res, params, 'wrong password — try again', client);
462
515
  }
463
- } else if (pw) {
516
+ } else if (pw && !proved) {
464
517
  // The login/approve screen names what is asking before the owner types
465
518
  // the password — a cross-site client cannot forge past that.
466
519
  return sendLoginForm(res, params, '', client);
467
- } else if (api.allowed && !api.allowed.isLocalRequest(req)) {
520
+ } else if (!proved && api.allowed && !api.allowed.isLocalRequest(req) && !api.throughDoor?.(req)) {
521
+ // With a pod to sign in at, the owner does that instead of a password.
522
+ if (req.method === 'GET' && ownerWebId) return sendPodSigninPage(res, client, ownerWebId, api.mount || '');
468
523
  // No password set, and this request did not come from this machine.
469
524
  //
470
525
  // The instant-authorize path is honest theatre on loopback: whoever can
@@ -477,10 +532,9 @@ export async function handle(api, ctx) {
477
532
  // dispatched before that check ever runs, so it needs its own.
478
533
  api.log(`authorize refused: no UI password, and "${req.headers.host}" is not this machine`);
479
534
  return send(403, {
480
- error: 'this agent answers on an address outside this machine and has no password set — '
481
- + (api.embedded
482
- ? 'POST {"password":"…"} to the owner door\'s /config with its door secret before logging in'
483
- : 'run `fedipod passwd` before logging in over that address'),
535
+ error: api.embedded
536
+ ? 'Open your account\'s management page first, then sign in from there.'
537
+ : 'Signing in from another machine needs a password. On the machine that runs your account, run: fedipod passwd',
484
538
  });
485
539
  }
486
540
  // External clients get a bound code; the local flow keeps code==token.
@@ -493,6 +547,9 @@ export async function handle(api, ctx) {
493
547
  const target = new URL(redirect);
494
548
  target.searchParams.set('code', code);
495
549
  if (params.get('state')) target.searchParams.set('state', params.get('state'));
550
+ // A proved sign-in came from the page's own request, which cannot follow
551
+ // a redirect to a client's address; the page is told where to go.
552
+ if (proved) return send(200, { redirect: target.href });
496
553
  res.writeHead(302, { location: target.href });
497
554
  res.end();
498
555
  return true;
@@ -6,6 +6,8 @@
6
6
  // served off disk, and the dispatch to the routes modules beside it. Each of
7
7
  // those exports get() and post() over the same ctx.
8
8
 
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
9
11
  import { MastoApi } from '../../client/masto/index.mjs';
10
12
  import { C2S } from '../../client/c2s.mjs';
11
13
  import { makeC2sAuth } from '../../client/oidc-auth.mjs';
@@ -75,12 +77,23 @@ const ROUTES = [owner, setup, lifecycle, gateway, social, connections];
75
77
  // meaning inside a server that is not one — and moves the gate: a pod that is
76
78
  // a fediverse instance must let strangers reach /api and /oauth, so the gate
77
79
  // guards the operator's door (basePath) instead of the whole surface.
80
+ const SESSION_FILES = {
81
+ '/oauth/session/signin.mjs': 'web/admin/oauth-signin.mjs',
82
+ '/oauth/session/fedi-login.mjs': 'lib/session/fedi-login.mjs',
83
+ '/oauth/session/oidc-session.mjs': 'lib/session/oidc-session.mjs',
84
+ };
85
+
78
86
  export function buildAdminSurface({ agent, gate, allowed, log = console.log,
79
87
  port = null, handle = null, embedded = false, basePath = '/', mount = '',
80
88
  publicOrigin = null, scheme = null,
81
89
  versionOnDisk = () => localVersion(projectRoot) }) {
82
90
  const json = (res, status, obj) => sendJson(res, status, obj, allowed);
83
- const masto = new MastoApi({ agent, log, allowed, scheme, embedded, mount });
91
+ const masto = new MastoApi({ agent, log, allowed, scheme, embedded, mount,
92
+ throughDoor: (req) => Boolean(gate?.upgradeOk?.(req)) });
93
+ // A client's sign-in can be proved with the owner's pod sign-in instead of a
94
+ // password: the same verification the post door uses, and the owner it names.
95
+ masto.podAuth = makeC2sAuth({ agent, log, scheme, mount });
96
+ masto.ownerWebId = () => agent.remote?.webId || null;
84
97
  // The spec's own write API (§6), beside the facade. Its bearer fallback is
85
98
  // the facade's token, so the two surfaces share one notion of the operator.
86
99
  const c2s = new C2S({ agent, log, scheme, mount,
@@ -172,6 +185,16 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
172
185
  if (p === '/ap/outbox' || p === '/ap/actor' || p === '/ap/inbox') {
173
186
  if (await c2s.handle(req, res, p, url)) return;
174
187
  }
188
+ // The sign-in page's script and the library it signs in with, outside
189
+ // the door like the sign-in itself. A fixed list; nothing in the request
190
+ // names a file.
191
+ const sessionFile = SESSION_FILES[p];
192
+ if (sessionFile) {
193
+ let body;
194
+ try { body = fs.readFileSync(path.join(projectRoot, sessionFile)); } catch { return json(res, 404, { error: 'not here' }); }
195
+ res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' });
196
+ res.end(body); return;
197
+ }
175
198
  // Where a client looks first to find out how to sign in (RFC 8414), and
176
199
  // in front of the door for the same reason C2S is: a client that has to
177
200
  // be handed a secret before it can ask how to sign in cannot set itself
@@ -71,7 +71,16 @@ function nameConstraintsExtension() {
71
71
  return { id: '2.5.29.30', critical: true, value: der.getBytes() };
72
72
  }
73
73
 
74
- function makeCert({ subjectCN, issuer = null, issuerKeyPem = null, isCA = false, days, extraDns = [] }) {
74
+ // The issuer's own key identifier, for the certificate it signs to name. A
75
+ // verifier that finds several CAs under one name — this machine's browser
76
+ // store has held dozens of "FediPod Local CA" from scratch agents — picks by
77
+ // this, and without it picks by name, tries the wrong key and fails.
78
+ const issuerKeyId = (issuerCertPem) => {
79
+ const ext = forge.pki.certificateFromPem(issuerCertPem).getExtension('subjectKeyIdentifier');
80
+ return ext?.subjectKeyIdentifier ? forge.util.hexToBytes(ext.subjectKeyIdentifier) : null;
81
+ };
82
+
83
+ function makeCert({ subjectCN, issuer = null, issuerKeyPem = null, issuerCertPem = null, isCA = false, days, extraDns = [] }) {
75
84
  const keys = forge.pki.rsa.generateKeyPair({ bits: 2048 });
76
85
  const cert = forge.pki.createCertificate();
77
86
  cert.publicKey = keys.publicKey;
@@ -89,6 +98,9 @@ function makeCert({ subjectCN, issuer = null, issuerKeyPem = null, isCA = false,
89
98
  : [{ name: 'basicConstraints', cA: false },
90
99
  { name: 'keyUsage', digitalSignature: true, keyEncipherment: true },
91
100
  { name: 'extKeyUsage', serverAuth: true },
101
+ { name: 'subjectKeyIdentifier' },
102
+ ...(issuerCertPem && issuerKeyId(issuerCertPem)
103
+ ? [{ name: 'authorityKeyIdentifier', keyIdentifier: issuerKeyId(issuerCertPem) }] : []),
92
104
  { name: 'subjectAltName',
93
105
  altNames: [...SANS, ...extraDns.map(v => ({ type: 2, value: v }))] }]);
94
106
  const signingKey = issuerKeyPem ? forge.pki.privateKeyFromPem(issuerKeyPem) : keys.privateKey;
@@ -131,6 +143,7 @@ export function ensureLocalTls(dir, { log = () => {}, names = [] } = {}) {
131
143
  const trust = !!(caKey && caCert);
132
144
  const isStale = (k, c) => !k || !c || daysLeft(c) < REMINT_DAYS || !coversLocalhost(c)
133
145
  || (trust && !issuedBy(c, caCert))
146
+ || (trust && !namesIssuerKey(c))
134
147
  || names.some(n => !certDnsNames(c).includes(n));
135
148
  let key = readPem(dir, FILES.key);
136
149
  let cert = readPem(dir, FILES.cert);
@@ -165,7 +178,7 @@ export function ensureLocalTls(dir, { log = () => {}, names = [] } = {}) {
165
178
  .filter(n => n !== 'localhost' && n !== '*.localhost');
166
179
  const minted = makeCert({
167
180
  subjectCN: 'localhost',
168
- ...(trust ? { issuer: caSubject(caCert), issuerKeyPem: caKey } : {}),
181
+ ...(trust ? { issuer: caSubject(caCert), issuerKeyPem: caKey, issuerCertPem: caCert } : {}),
169
182
  days: LEAF_DAYS,
170
183
  extraDns: carried,
171
184
  });
@@ -181,6 +194,9 @@ export function ensureLocalTls(dir, { log = () => {}, names = [] } = {}) {
181
194
  return { key, cert, trust, dir, expiresInDays: Math.floor(daysLeft(cert)) };
182
195
  }
183
196
 
197
+ const namesIssuerKey = (certPem) => {
198
+ try { return !!forge.pki.certificateFromPem(certPem).getExtension('authorityKeyIdentifier'); } catch { return false; }
199
+ };
184
200
  const caSubject = (caCertPem) => forge.pki.certificateFromPem(caCertPem).subject.attributes
185
201
  .map(a => ({ name: a.name, value: a.value }));
186
202
  const issuedBy = (certPem, caCertPem) => {
@@ -221,11 +221,22 @@ function parseUserPath(pathname) {
221
221
  // `netlify-cdn-cache-control` is the edge's own instruction; `cache-control`
222
222
  // is what the reader's browser is told. The edge may serve a stale copy while
223
223
  // it fetches a fresh one, so nobody waits on the pod for a document that is
224
- // merely a minute old.
224
+ // merely minutes old. The edge holds a document far longer than a browser
225
+ // is asked to: the edge is what turns a thousand readers into one function
226
+ // call, while a browser that held an actor for ten minutes would show one
227
+ // person a stale profile they had just edited.
228
+ const BROWSER_MAX_AGE = 60;
225
229
  const publicFor = (seconds, stale = seconds * 4) => ({
226
- 'cache-control': `public, max-age=${seconds}`,
230
+ 'cache-control': `public, max-age=${Math.min(seconds, BROWSER_MAX_AGE)}`,
227
231
  'netlify-cdn-cache-control': `public, s-maxage=${seconds}, stale-while-revalidate=${stale}`,
228
232
  });
233
+ // How long the edge holds a public document, and a handle's WebFinger
234
+ // answer. Every server that has heard of an account asks for its actor and
235
+ // its handle again and again; a post or a collection changes when its owner
236
+ // acts, and ten minutes of edge is what the plan's budget of function calls
237
+ // can afford.
238
+ const PUBLIC_EDGE_SECONDS = 600;
239
+ const WEBFINGER_EDGE_SECONDS = 3600;
229
240
 
230
241
  // Where a pod owner opts their identity in. An operator may name it, because
231
242
  // the path it takes is one their pod server can no longer serve; the dot says
@@ -382,7 +393,7 @@ async function route(request, ctx) {
382
393
  if (pathname === runPath(ctx)) {
383
394
  if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: {}, body: '' };
384
395
  if (!ctx.runPage) return notFound();
385
- return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: ctx.runPage };
396
+ return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' }, body: ctx.runPage };
386
397
  }
387
398
 
388
399
  // The roster page: the host reading who has accounts here. The page signs in
@@ -609,6 +620,28 @@ async function route(request, ctx) {
609
620
  return j(200, { results });
610
621
  }
611
622
 
623
+ // Who is here, and which pod of theirs could opt in. The opt-in page asks
624
+ // this first: on a pod server that issued the reader's session, the answer
625
+ // names their pod and its address, and the page shows one button instead of
626
+ // a form. A Gateway holds no such session and says so; the page then asks
627
+ // for the pod by hand and proves it with a sign-in, as before.
628
+ if (pathname === '/api/agent' && request.method === 'GET') {
629
+ if (!ctx.agentControl) return j(501, { error: 'this server does not run identities' });
630
+ if (!ctx.webIdsFromSession) return j(200, { offered: true, session: 'unknown' });
631
+ if (!sameOriginRequest(request, ctx.frontOrigin)) return j(200, { offered: true, session: 'none' });
632
+ const webIds = await ctx.webIdsFromSession(request).catch(() => []);
633
+ if (!webIds.length) return j(200, { offered: true, session: 'none', loginUrl: '/.account/login/password/' });
634
+ const pods = [];
635
+ for (const webId of webIds) {
636
+ let podBase;
637
+ try { podBase = podBaseOfWebId(webId); } catch { continue; }
638
+ let about = null;
639
+ try { about = ctx.agentControl.describe ? await ctx.agentControl.describe({ podBase }) : null; } catch { about = null; }
640
+ pods.push({ webId, podBase, ...(about || {}) });
641
+ }
642
+ return j(200, { offered: true, session: 'ok', pods });
643
+ }
644
+
612
645
  // Runtime opt-in: a pod owner asks the server this front sits on to RUN
613
646
  // their identity. Same proof as attach — a Solid-OIDC token — but the
614
647
  // ownership check is stricter: the WebID must live UNDER the pod base, not
@@ -691,7 +724,9 @@ async function route(request, ctx) {
691
724
  if (mPageScript) {
692
725
  const body = ctx.pageScripts?.[`${mPageScript[1]}.js`];
693
726
  if (!body) return notFound();
694
- return { status: 200, headers: { 'content-type': 'text/javascript; charset=utf-8' }, body };
727
+ // Revalidated on every load: a page served fresh with a script the
728
+ // browser kept from last time is the mismatch nobody can see.
729
+ return { status: 200, headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' }, body };
695
730
  }
696
731
 
697
732
  // The vendored Solid-OIDC browser library the opt-in and admin pages load —
@@ -727,7 +762,7 @@ async function route(request, ctx) {
727
762
  // A handle answers the same for everybody, and changes only when a row
728
763
  // does. Every server that has heard of this account asks for it; the edge
729
764
  // can answer most of them.
730
- Object.assign(out.headers, publicFor(300));
765
+ Object.assign(out.headers, publicFor(WEBFINGER_EDGE_SECONDS));
731
766
  return out;
732
767
  }
733
768
 
@@ -856,7 +891,7 @@ async function route(request, ctx) {
856
891
  // `readPublicDocument` asked for it with no credential, so a document that
857
892
  // came back is one anybody can read — which is what makes it cacheable.
858
893
  return { status: 200,
859
- headers: { ...open, 'content-type': got.type || (container ? 'text/turtle' : page ? 'text/html' : AP_CT), ...publicFor(30) },
894
+ headers: { ...open, 'content-type': got.type || (container ? 'text/turtle' : page ? 'text/html' : AP_CT), ...publicFor(PUBLIC_EDGE_SECONDS) },
860
895
  body: text };
861
896
  }
862
897
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "1.26.1",
3
+ "version": "1.27.1",
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/vendor/gate.cjs CHANGED
@@ -7,8 +7,11 @@
7
7
  // authorized when it carries the secret:
8
8
  // 1. header `x-dk-token` — the Electron shell injects this on app traffic, or
9
9
  // 2. cookie `dk-token` — a browser "blessed" via the token URL, or
10
- // 3. query `?dk-token=<secret>` — answered with a SameSite=Strict cookie and
11
- // a redirect to the same URL minus the param (the blessing flow), or
10
+ // 3. query `?dk-token=<secret>` — answered with a SameSite=Lax cookie and
11
+ // a redirect to the same URL minus the param (the blessing flow). Lax,
12
+ // not Strict: a link from another site — the pod server's own opt-in
13
+ // page among them — must arrive with the cookie it was just handed, and
14
+ // Lax still withholds it from cross-site POSTs and subresources; or
12
15
  // 4. (opt-in, proxy only) an Origin/Referer on the allowOrigins list — app
13
16
  // pages in a blessed browser call the proxy with plain fetch(), which
14
17
  // doesn't attach cookies cross-port; a hostile page can't forge Origin.
@@ -105,7 +108,7 @@ function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCoo
105
108
  // Path scopes the cookie to this identity's own door: on a shared
106
109
  // origin (suffix pods) two co-tenants must not clobber each other's,
107
110
  // and a whole-origin cookie would. Defaults to '/'.
108
- 'set-cookie': `${COOKIE}=${t}; Path=${cookiePath}; HttpOnly; SameSite=Strict; Max-Age=31536000`
111
+ 'set-cookie': `${COOKIE}=${t}; Path=${cookiePath}; HttpOnly; SameSite=Lax; Max-Age=31536000`
109
112
  + (secureCookie ? '; Secure' : ''),
110
113
  'location': url.pathname + url.search,
111
114
  });
@@ -0,0 +1,37 @@
1
+ // oauth-signin.mjs — a client asked this account to sign its owner in. The
2
+ // owner signs in at their own pod, with the fediverse-account library, and the
3
+ // page proves that sign-in to the account with one signed request; the account
4
+ // then hands the client its code. No password of ours anywhere.
5
+ import { fediLogin } from './fedi-login.mjs';
6
+
7
+ const $ = (id) => document.getElementById(id);
8
+ const say = (text, error = false) => { const s = $('signin-status'); s.textContent = text; s.setAttribute('role', error ? 'alert' : 'status'); };
9
+ const webId = $('webid').href;
10
+ const here = location.origin + location.pathname;
11
+ const login = fediLogin({ dbName: 'fedipod-signin', clientName: 'FediPod sign-in', redirectUri: here });
12
+
13
+ // Back from the pod: this finishes the sign-in and returns to the client's
14
+ // request, which carries the client's own parameters.
15
+ try { await login.resume(); } catch (e) { say(e.message, true); }
16
+
17
+ async function prove() {
18
+ const s = await login.getSession();
19
+ if (!s) return false;
20
+ if (s.webId !== webId) { say(`You signed in as ${s.webId}, but this account belongs to ${webId}. Sign out there and sign in as the owner.`, true); return true; }
21
+ say('Signing you in…');
22
+ const res = await s.fetch(here, {
23
+ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
24
+ body: location.search.replace(/^\?/u, ''),
25
+ });
26
+ const d = await res.json().catch(() => ({}));
27
+ if (res.ok && d.redirect) { location.href = d.redirect; return true; }
28
+ if (res.ok && d.code) { say(`Your authorization code: ${d.code}`); return true; }
29
+ say(d.error || `The account refused the sign-in (HTTP ${res.status}).`, true);
30
+ return true;
31
+ }
32
+
33
+ if (location.search.includes('client_id=') && !(await prove())) $('signin').hidden = false;
34
+ $('signin').addEventListener('click', async () => {
35
+ say('Taking you to your pod…');
36
+ try { await login.login(webId, { returnTo: location.href, redirectUri: here }); } catch (e) { say(e.message, true); }
37
+ });
@@ -19,8 +19,8 @@ main { flex: 1 1 auto; min-height: 0; display: flex; }
19
19
  </style>
20
20
  <link rel="stylesheet" href="../bar.css">
21
21
  <style>#actor-pick{display:none!important}</style>
22
- <meta name="fedipod-version" content="1.26.1">
23
- <meta name="fedipod-build" content="a59138536e01">
22
+ <meta name="fedipod-version" content="1.27.1">
23
+ <meta name="fedipod-build" content="709aafeeaee1">
24
24
  <script src="/update.js" defer></script>
25
25
  <script src="/admin/client-pick.js"></script>
26
26
  </head>
@@ -19,8 +19,8 @@ main { flex: 1 1 auto; min-height: 0; display: flex; }
19
19
  </style>
20
20
  <link rel="stylesheet" href="../bar.css">
21
21
  <style>#actor-pick{display:none!important}</style>
22
- <meta name="fedipod-version" content="1.26.1">
23
- <meta name="fedipod-build" content="a59138536e01">
22
+ <meta name="fedipod-version" content="1.27.1">
23
+ <meta name="fedipod-build" content="709aafeeaee1">
24
24
  <script src="/update.js" defer></script>
25
25
  <script src="/admin/client-pick.js"></script>
26
26
  </head>
@@ -168,8 +168,8 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
168
168
  <link rel="stylesheet" href="bar.css?v=3">
169
169
  <link rel="stylesheet" href="window.css">
170
170
  <style>#gw-shape-front,#do-drain,#do-log,#actor-pick{display:none!important}</style>
171
- <meta name="fedipod-version" content="1.26.1">
172
- <meta name="fedipod-build" content="a59138536e01">
171
+ <meta name="fedipod-version" content="1.27.1">
172
+ <meta name="fedipod-build" content="709aafeeaee1">
173
173
  <script src="/update.js" defer></script>
174
174
  <script src="/admin/client-pick.js"></script>
175
175
  </head>
@@ -0,0 +1,37 @@
1
+ // oauth-signin.mjs — a client asked this account to sign its owner in. The
2
+ // owner signs in at their own pod, with the fediverse-account library, and the
3
+ // page proves that sign-in to the account with one signed request; the account
4
+ // then hands the client its code. No password of ours anywhere.
5
+ import { fediLogin } from './fedi-login.mjs';
6
+
7
+ const $ = (id) => document.getElementById(id);
8
+ const say = (text, error = false) => { const s = $('signin-status'); s.textContent = text; s.setAttribute('role', error ? 'alert' : 'status'); };
9
+ const webId = $('webid').href;
10
+ const here = location.origin + location.pathname;
11
+ const login = fediLogin({ dbName: 'fedipod-signin', clientName: 'FediPod sign-in', redirectUri: here });
12
+
13
+ // Back from the pod: this finishes the sign-in and returns to the client's
14
+ // request, which carries the client's own parameters.
15
+ try { await login.resume(); } catch (e) { say(e.message, true); }
16
+
17
+ async function prove() {
18
+ const s = await login.getSession();
19
+ if (!s) return false;
20
+ if (s.webId !== webId) { say(`You signed in as ${s.webId}, but this account belongs to ${webId}. Sign out there and sign in as the owner.`, true); return true; }
21
+ say('Signing you in…');
22
+ const res = await s.fetch(here, {
23
+ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
24
+ body: location.search.replace(/^\?/u, ''),
25
+ });
26
+ const d = await res.json().catch(() => ({}));
27
+ if (res.ok && d.redirect) { location.href = d.redirect; return true; }
28
+ if (res.ok && d.code) { say(`Your authorization code: ${d.code}`); return true; }
29
+ say(d.error || `The account refused the sign-in (HTTP ${res.status}).`, true);
30
+ return true;
31
+ }
32
+
33
+ if (location.search.includes('client_id=') && !(await prove())) $('signin').hidden = false;
34
+ $('signin').addEventListener('click', async () => {
35
+ say('Taking you to your pod…');
36
+ try { await login.login(webId, { returnTo: location.href, redirectUri: here }); } catch (e) { say(e.message, true); }
37
+ });
@@ -54,8 +54,8 @@ button:disabled { opacity: .5; cursor: default; }
54
54
  </style>
55
55
  <link rel="stylesheet" href="../bar.css">
56
56
  <style>#actor-pick{display:none!important}</style>
57
- <meta name="fedipod-version" content="1.26.1">
58
- <meta name="fedipod-build" content="a59138536e01">
57
+ <meta name="fedipod-version" content="1.27.1">
58
+ <meta name="fedipod-build" content="709aafeeaee1">
59
59
  <script src="/update.js" defer></script>
60
60
  </head>
61
61
  <body>
@@ -12,8 +12,8 @@
12
12
  <link rel="modulepreload" crossorigin href="../assets/chunk-aKtaBQYM.js">
13
13
  <link rel="modulepreload" crossorigin href="../assets/pwa-viewport-ogKbF51K.js">
14
14
 
15
- <link rel="manifest" href="./manifest.webmanifest"><meta name="fedipod-version" content="1.26.1">
16
- <meta name="fedipod-build" content="a59138536e01">
15
+ <link rel="manifest" href="./manifest.webmanifest"><meta name="fedipod-version" content="1.27.1">
16
+ <meta name="fedipod-build" content="709aafeeaee1">
17
17
  <script src="/update.js" defer></script>
18
18
  </head>
19
19
  <body>
@@ -65,8 +65,8 @@
65
65
  <link rel="modulepreload" crossorigin href="./assets/ICONS-DIPJeU0C.js">
66
66
  <link rel="modulepreload" crossorigin href="./assets/ScheduledAtField-D-Abfo4J.js">
67
67
 
68
- <link rel="manifest" href="./manifest.webmanifest"><meta name="fedipod-version" content="1.26.1">
69
- <meta name="fedipod-build" content="a59138536e01">
68
+ <link rel="manifest" href="./manifest.webmanifest"><meta name="fedipod-version" content="1.27.1">
69
+ <meta name="fedipod-build" content="709aafeeaee1">
70
70
  <script src="/update.js" defer></script>
71
71
  </head>
72
72
  <body>
@@ -35,8 +35,8 @@
35
35
  .hint { color: var(--sub); }
36
36
  </style>
37
37
  <script src="/authorize_interaction.js" defer></script>
38
- <meta name="fedipod-version" content="1.26.1">
39
- <meta name="fedipod-build" content="a59138536e01">
38
+ <meta name="fedipod-version" content="1.27.1">
39
+ <meta name="fedipod-build" content="709aafeeaee1">
40
40
  <script src="/update.js" defer></script>
41
41
  </head>
42
42
  <body>
@@ -211,8 +211,8 @@
211
211
  /* At the foot of the page, centred, clear of whatever is above it. */
212
212
  footer.foot { margin: 1rem auto 0; color: var(--sub); font-size: 16px; text-align: center; }
213
213
  </style>
214
- <meta name="fedipod-version" content="1.26.1">
215
- <meta name="fedipod-build" content="a59138536e01">
214
+ <meta name="fedipod-version" content="1.27.1">
215
+ <meta name="fedipod-build" content="709aafeeaee1">
216
216
  <script src="/update.js" defer></script>
217
217
  </head>
218
218
  <body>
@@ -1 +1 @@
1
- {"build":"a59138536e01","version":"1.26.1","at":"2026-09-20T18:26:18.671Z"}
1
+ {"build":"709aafeeaee1","version":"1.27.1","at":"2026-09-21T23:13:33.708Z"}
@@ -72,8 +72,8 @@ button:disabled { opacity: .55; cursor: default; }
72
72
  a { color: var(--accent); }
73
73
  [hidden] { display: none !important; }
74
74
  </style>
75
- <meta name="fedipod-version" content="1.26.1">
76
- <meta name="fedipod-build" content="a59138536e01">
75
+ <meta name="fedipod-version" content="1.27.1">
76
+ <meta name="fedipod-build" content="709aafeeaee1">
77
77
  <script src="/update.js" defer></script>
78
78
  </head>
79
79
  <body>
@@ -49,6 +49,5 @@ accounts that only use it as their gateway. Sign in as the server's admin to see
49
49
  </table>
50
50
 
51
51
  <script type="module" src="/admin.js"></script>
52
- <i>(cc) 4.0 By, Jeff Zucker, 2026</i>
53
52
  </body>
54
53
  </html>
@@ -31,6 +31,5 @@ FediPod is experimental and free to use. See the <a href="https://github.com/je
31
31
  </p>
32
32
 
33
33
  <script src="/new-account.js"></script>
34
- <i style="text-align:right">(cc) 4.0 By, Jeff Zucker, 2026</i>
35
34
  </body>
36
35
  </html>
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>FediPod — run your identity on this server</title>
6
+ <title>FediPod — turn your pod into a Fediverse account</title>
7
7
  <link rel="icon" href="/icons/gateway.svg" type="image/svg+xml">
8
8
  <style>
9
9
  :root { color-scheme: light dark; --line:#d9d4c7; --dim:#45443d; --accent:#6a8a5a; }
@@ -15,20 +15,27 @@
15
15
  input[type=text], input[type=url] { width: 100%; padding: .5rem .6rem; font: inherit;
16
16
  border: 1px solid var(--line); border-radius: 8px; box-sizing: border-box; }
17
17
  .hint { color: var(--dim); font-size: .95rem; }
18
+ .pod { border: 1px solid var(--line); border-radius: 9px; padding: .9rem 1.1rem; margin: .8rem 0; }
19
+ .pod p { margin: .3rem 0; }
20
+ .pod .actions { margin-top: .8rem; }
18
21
  .primary { background: var(--accent); color: #fff; border: none; border-radius: 9px;
19
22
  padding: .6rem 1.1rem; font: inherit; cursor: pointer; }
20
23
  button { font: inherit; }
21
24
  .primary[disabled], button[disabled] { opacity: .5; cursor: not-allowed; }
22
25
  [hidden] { display: none !important; }
23
26
  code { background: rgba(120,120,120,.12); padding: .05em .35em; border-radius: 4px; }
27
+ .pod code { background: #fff3c4; color: #1a1a1a; font-size: 16px; }
28
+ @media (prefers-color-scheme: dark) { .pod code { background: #5a4a00; color: #fff; } }
24
29
  pre { background: rgba(120,120,120,.12); padding: .7rem .9rem; border-radius: 8px; overflow-x: auto; }
25
30
  </style>
26
31
  </head>
27
32
  <body>
28
- <h1>Run your identity on this server</h1>
29
- <p class="lede">If your pod is hosted by this server, the server can run your Fediverse identity
30
- for you: it accepts follows, delivers your posts and serves your Mastodon app, with nothing for
31
- you to keep running. Sign in with your pod to prove it is yours.</p>
33
+ <h1>Turn your pod into a Fediverse account</h1>
34
+ <p class="lede">Users with pods on this server may opt-in below to create a Fediverse account
35
+ that stores your data on your pod. You'll be able to interact with Mastodon and users of other
36
+ Fediverse services straight from your pod.</p>
37
+
38
+ <section id="run-known" hidden aria-live="polite"></section>
32
39
 
33
40
  <form id="run-form">
34
41
  <p class="field">
@@ -43,10 +50,9 @@ you to keep running. Sign in with your pod to prove it is yours.</p>
43
50
  <button type="button" id="run-continue" class="primary" disabled>Sign in with your pod &amp; opt in</button>
44
51
  <button type="button" id="run-leave">Stop running it here</button>
45
52
  </p>
46
- <p class="hint" id="run-note" hidden></p>
47
53
  </form>
54
+ <p class="hint" id="run-note" hidden></p>
48
55
 
49
56
  <script type="module" src="/run.js"></script>
50
- <i>(cc) 4.0 By, Jeff Zucker, 2026</i>
51
57
  </body>
52
58
  </html>
package/web/front/run.js CHANGED
@@ -23,22 +23,80 @@ try {
23
23
  session = new SessionCore({ redirect_uris: [HERE], client_name: 'FediPod gateway' });
24
24
  } catch { /* leave null — the "did not load" notes below fire */ }
25
25
 
26
- // Offered only where this server actually runs identities. An
27
- // unauthenticated probe tells the two apart: 501 = not offered here,
28
- // anything else = offered (a real opt-in still needs the signed-in proof).
26
+ // What this server knows before anything is typed. 501: it runs no
27
+ // identities. A pod server that issued the reader's session names their pod
28
+ // and its address, and the page shows one button per pod. Not signed in
29
+ // there: one button, to its login. A Gateway knows nothing: the form below.
29
30
  let offered = true;
31
+ const say = (tag, text) => { const e = document.createElement(tag); e.textContent = text; return e; };
30
32
  (async () => {
31
- const res = await fetch('/api/agent', { method: 'POST',
32
- headers: { 'content-type': 'application/json' }, body: '{}' }).catch(() => null);
33
+ const res = await fetch('/api/agent', { credentials: 'same-origin', headers: { accept: 'application/json' } }).catch(() => null);
34
+ const d = res && res.ok ? await res.json().catch(() => ({})) : {};
35
+ const n = $('run-note');
33
36
  if (!res || res.status === 501) {
34
37
  offered = false;
35
- const n = $('run-note');
36
38
  n.hidden = false;
37
39
  n.textContent = 'This server does not run identities. Your agent stays where it is.';
40
+ } else if (d.session === 'none') {
41
+ $('run-form').hidden = true;
42
+ showSignIn(d.loginUrl);
43
+ } else if (d.session === 'ok' && Array.isArray(d.pods) && d.pods.length) {
44
+ $('run-form').hidden = true;
45
+ showPods(d.pods);
38
46
  }
39
47
  runFormCheck();
40
48
  })();
41
49
 
50
+ async function refreshKnown() {
51
+ const res = await fetch('/api/agent', { credentials: 'same-origin', headers: { accept: 'application/json' } }).catch(() => null);
52
+ const d = res && res.ok ? await res.json().catch(() => ({})) : {};
53
+ if (d.session === 'ok' && Array.isArray(d.pods) && d.pods.length) showPods(d.pods);
54
+ }
55
+
56
+ function showSignIn(loginUrl) {
57
+ const k = $('run-known');
58
+ k.hidden = false;
59
+ k.replaceChildren();
60
+ k.append(say('p', 'You are not signed in at this server.'));
61
+ if (loginUrl) {
62
+ const go = say('button', 'Sign in');
63
+ go.type = 'button'; go.className = 'primary';
64
+ go.onclick = () => { location.href = loginUrl; };
65
+ const p = document.createElement('p'); p.className = 'actions'; p.append(go);
66
+ k.append(p, say('p', 'Sign in, then come back to this page.'));
67
+ k.lastChild.className = 'hint';
68
+ }
69
+ }
70
+
71
+ function showPods(pods) {
72
+ const k = $('run-known');
73
+ k.hidden = false;
74
+ k.replaceChildren();
75
+ for (const pod of pods) {
76
+ const box = document.createElement('div'); box.className = 'pod';
77
+ // The names stand out from the sentences around them.
78
+ const line = (before, id, after = '.') => { const p = say('p', before); p.append(say('code', id), after); return p; };
79
+ box.append(line('Solid Identity (WebID): ', pod.webId, ''));
80
+ const actions = document.createElement('p'); actions.className = 'actions';
81
+ box.append(line('Fediverse Identity: ', pod.address, ''));
82
+ if (pod.running) {
83
+ const links = say('p', 'Manage your account: ');
84
+ const m = say('a', pod.manage.split('?')[0]); m.href = pod.manage;
85
+ links.append(m);
86
+ box.append(links);
87
+ const stop = say('button', 'Close this Fediverse account'); stop.type = 'button'; stop.className = 'primary';
88
+ stop.onclick = () => runStart('opt-out', pod.podBase);
89
+ actions.append(stop);
90
+ } else {
91
+ const go = say('button', 'Create a Fediverse account'); go.type = 'button'; go.className = 'primary';
92
+ go.onclick = () => runStart('opt-in', pod.podBase);
93
+ actions.append(go);
94
+ }
95
+ box.append(actions);
96
+ k.append(box);
97
+ }
98
+ }
99
+
42
100
  function runFormCheck() {
43
101
  let ok = false;
44
102
  try { ok = /^https?:$/.test(new URL($('run-pod-url').value.trim()).protocol); } catch { /* not yet */ }
@@ -63,9 +121,9 @@ $('run-issuer').addEventListener('input', runFormCheck);
63
121
  // a filled-in form, with nothing to say why.
64
122
  runFormCheck();
65
123
 
66
- const runStart = async (action) => {
124
+ const runStart = async (action, podBase = null) => {
67
125
  const n = $('run-note');
68
- const u = new URL($('run-pod-url').value.trim());
126
+ const u = new URL(podBase || $('run-pod-url').value.trim());
69
127
  if (u.pathname === '') u.pathname = '/';
70
128
  if (!u.pathname.endsWith('/')) u.pathname += '/';
71
129
  u.search = ''; u.hash = '';
@@ -103,16 +161,14 @@ async function showReply(res, p) {
103
161
  // back from the server and one comes from sessionStorage, and pasting any
104
162
  // of them into innerHTML means whatever markup they contain is rendered.
105
163
  // Only the fixed wording is markup here; everything variable is text.
106
- const say = (tag, text) => { const e = document.createElement(tag); e.textContent = text; return e; };
107
- n.textContent = 'Your identity runs here now. This is your door secret — ';
108
- n.append(say('b', 'save it now'));
109
- n.append(', it is shown only this once. It opens your admin pages at ');
110
- n.append(say('code', p.podBase.replace(/\/$/, '') + d.doorPath));
111
- n.append(':', say('pre', d.doorSecret));
112
- n.append('To check it works:', say('pre', d.command));
113
- n.append('Lost it? Opt in again — that mints a fresh one and retires this one.');
164
+ const manage = `${p.podBase.replace(/\/$/, '')}${d.doorPath}?dk-token=${encodeURIComponent(d.doorSecret)}`;
165
+ n.textContent = 'Your Fediverse account is ready. Manage it at ';
166
+ const a = say('a', p.podBase.replace(/\/$/, '') + d.doorPath); a.href = manage;
167
+ n.append(a, '.');
168
+ refreshKnown();
114
169
  } else if (res && res.status === 200) {
115
- n.textContent = 'Done — this server no longer runs that identity. Your pod and its data are untouched.';
170
+ n.textContent = 'Your Fediverse account is closed. Your pod and its data are untouched.';
171
+ refreshKnown();
116
172
  } else {
117
173
  n.textContent = (p.action === 'opt-in' ? 'opt-in' : 'opt-out') + ' failed: '
118
174
  + (d.error || (res ? 'HTTP ' + res.status : 'no response'));