fedipod 1.48.0 → 1.49.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.
@@ -18,16 +18,17 @@
18
18
  import crypto from 'node:crypto';
19
19
  import { handleDelivery } from './gateway-core.mjs';
20
20
  import { ownerPostAtDoor } from './outbox-door.mjs';
21
- import { readCapped, safeFetch, isLoopbackHost } from '../shared/safefetch.mjs';
21
+ import { readCapped, safeFetch, isLoopbackHost, namesPrivateAddress } from '../shared/safefetch.mjs';
22
22
  import * as podRoot from '../pod/root.mjs';
23
23
  import * as podPolicy from '../pod/policy.mjs';
24
24
  import { podBaseOfWebId } from '../pod/urls.mjs';
25
25
  import { routeQuietApi, noteOpened, noteReceived, closedState, accountState, closedAnswer, provedOwner } from './quiet.mjs';
26
- import { holdsMail, mayBeAway, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi } from './held-mail.mjs';
26
+ import { holdsMail, mayBeAway, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi, routePushApi } from './held-mail.mjs';
27
27
  import { routeNoticesApi } from './notices.mjs';
28
28
  import { withSecurityHeaders } from './headers.mjs';
29
- import { podTokenVerifier } from './caches.mjs';
30
- import { claimedWebId } from './token-claims.mjs';
29
+ import { claimedWebId, verifyPodToken } from './token-claims.mjs';
30
+
31
+ export { verifyPodToken };
31
32
  import { answerPrivateRead } from './private-read.mjs';
32
33
  import { announced, overLimit, relayOne } from './relay-extras.mjs';
33
34
 
@@ -35,16 +36,15 @@ import { announced, overLimit, relayOne } from './relay-extras.mjs';
35
36
  // wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
36
37
  // friends), which a serverless front must never carry — it crashed the
37
38
  // deployed function before it answered its first request.
38
- const jrd = ({ handle, host, actor, aliases = [], page = null, subscribe = null }) => ({
39
+ const jrd = ({ handle, host, actor, aliases = [], page = null, subscribe = null, avatar = null }) => ({
39
40
  subject: `acct:${handle}@${host}`,
40
41
  ...(aliases.length ? { aliases } : {}),
41
42
  links: [
42
43
  { rel: 'self', type: 'application/activity+json', href: actor },
43
44
  ...(page ? [{ rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: page }] : []),
44
- // Remote follow. Somebody reading this account on another server presses
45
- // Follow, says they are here, and that server looks for this link to find
46
- // out where to send them. `{uri}` is a literal the other server fills in
47
- // with whatever they are following — it must not be escaped.
45
+ ...(avatar ? [{ rel: 'http://webfinger.net/rel/avatar', href: avatar }] : []), // the account's picture
46
+ // Remote follow: another server sends its reader to this template to follow
47
+ // from there. `{uri}` is a literal that server fills in; never escape it.
48
48
  ...(subscribe ? [{ rel: 'http://ostatus.org/schema/1.0/subscribe', template: subscribe }] : []),
49
49
  ],
50
50
  });
@@ -57,29 +57,6 @@ const profilePageOf = (rec) => (rec?.podHome ? rec.podHome + 'ap/profile.html' :
57
57
 
58
58
  const AP_CT = 'application/activity+json';
59
59
 
60
- // Verify a Solid-OIDC token (DPoP-bound) and return its WebID, or null. The
61
- // verifier is injected so tests stub it; in production it is the same library
62
- // the agent's own C2S auth uses.
63
- export async function verifyPodToken(request, pathname, verifier) {
64
- const authz = request.headers.get('authorization');
65
- if (!authz) return null;
66
- try {
67
- const dpop = request.headers.get('dpop');
68
- // Solid-OIDC binds the token to a key the client proves on every request;
69
- // a token shown without the proof is one anyone who saw it could show.
70
- if (!dpop) { console.log(`front: pod token without a DPoP proof refused on ${pathname}`); return null; }
71
- const v = verifier || await podTokenVerifier();
72
- const url = request.url;
73
- const { webid } = await v(authz, { header: dpop, method: request.method, url });
74
- return webid || null;
75
- } catch (e) {
76
- // Said aloud: a token the front will not take is otherwise a bare 401 to
77
- // the caller and nothing at all here.
78
- console.log(`front: pod token refused on ${pathname}: ${e?.message || e}`);
79
- return null;
80
- }
81
- }
82
-
83
60
  // Is this WebID served by the claimed pod? A pod owner's WebID lives on the pod
84
61
  // origin — that is the whole proof: a token for a WebID under podHome could
85
62
  // only be minted by someone who controls that pod's identity provider.
@@ -105,6 +82,13 @@ function webidUnderPod(webid, podHome) {
105
82
  } catch { return false; }
106
83
  }
107
84
 
85
+ // Whether a WebID is this row's owner: the one recorded, or for a row from
86
+ // before WebIDs were recorded, one living on the row's pod.
87
+ const ownsRow = (rec) => (webid) => (rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome));
88
+
89
+ // A front on this machine: a test run, whose pods and identity providers are here too.
90
+ const frontOnThisMachine = (ctx) => { try { return isLoopbackHost(new URL(ctx.frontOrigin).hostname); } catch { return false; } };
91
+
108
92
  /**
109
93
  * Whether this WebID may speak for this pod.
110
94
  *
@@ -362,7 +346,7 @@ async function route(request, ctx) {
362
346
  // Solid-OIDC token — and must be the WebID the deploy names as admin.
363
347
  if (pathname === '/api/roster') {
364
348
  if (!ctx.listDirectory || !ctx.adminWebId) return j(501, { error: 'this front has no roster to offer' });
365
- const webid = await verifyPodToken(request, pathname, ctx.verifier);
349
+ const webid = await verifyPodToken(request, pathname, ctx.verifier, { only: (w) => w === ctx.adminWebId });
366
350
  if (!webid) return j(401, { error: 'a Solid-OIDC token is required' });
367
351
  if (webid !== ctx.adminWebId) return j(403, { error: 'that WebID is not the admin of this front' });
368
352
  const rows = await ctx.listDirectory();
@@ -382,7 +366,7 @@ async function route(request, ctx) {
382
366
  // for good; a row seeded in the deploy's environment can only be removed there.
383
367
  if (pathname === '/api/revoke' && request.method === 'POST') {
384
368
  if (!ctx.removeDirectory || !ctx.adminWebId) return j(501, { error: 'this front cannot revoke accounts' });
385
- const webid = await verifyPodToken(request, pathname, ctx.verifier);
369
+ const webid = await verifyPodToken(request, pathname, ctx.verifier, { only: (w) => w === ctx.adminWebId });
386
370
  if (!webid) return j(401, { error: 'a Solid-OIDC token is required' });
387
371
  if (webid !== ctx.adminWebId) return j(403, { error: 'that WebID is not the admin of this front' });
388
372
  let body;
@@ -471,7 +455,12 @@ async function route(request, ctx) {
471
455
  }
472
456
  const badPod = podHomeProblem(podHome, ctx.frontOrigin);
473
457
  if (badPod) return j(400, { error: badPod });
474
- const webid = await verifyPodToken(request, pathname, ctx.verifier);
458
+ // Anyone may sign up, so the pod and the token's addresses are a
459
+ // stranger's to name, and checking them fetches them. A front on the
460
+ // internet fetches only public ones; one on this machine, for testing, its own.
461
+ const publicOnly = !frontOnThisMachine(ctx);
462
+ if (publicOnly && await namesPrivateAddress(podHome)) return j(400, { error: 'that pod address is not on the public internet' });
463
+ const webid = await verifyPodToken(request, pathname, ctx.verifier, { publicOnly });
475
464
  if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
476
465
  if (!await provesPod(webid, podHome, ctx.fetchImpl)) {
477
466
  return j(403, { error: 'the token proves a different pod than the one you listed' });
@@ -572,10 +561,9 @@ async function route(request, ctx) {
572
561
  return withApiCors(j(400, { error: 'movedTo must be an https …/ap/actor URL' }));
573
562
  }
574
563
  if (ctx.frontOrigin && mu.origin === new URL(ctx.frontOrigin).origin) return withApiCors(j(400, { error: 'that address is at this gateway' }));
575
- const webid = await verifyPodToken(request, pathname, ctx.verifier);
564
+ const webid = await verifyPodToken(request, pathname, ctx.verifier, { only: ownsRow(rec) });
576
565
  if (!webid) return withApiCors(j(401, { error: 'a Solid-OIDC token proving the pod is required' }));
577
- const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
578
- if (!owner) return withApiCors(j(403, { error: "the token proves a different pod than this account's" }));
566
+ if (!ownsRow(rec)(webid)) return withApiCors(j(403, { error: "the token proves a different pod than this account's" }));
579
567
  const movedAt = rec.movedTo === movedTo && rec.movedAt ? rec.movedAt : new Date().toISOString();
580
568
  await ctx.putDirectory(handle, { ...rec, movedTo, movedAt });
581
569
  console.log(`front @${handle}: moved to ${movedTo}`);
@@ -585,9 +573,9 @@ async function route(request, ctx) {
585
573
  // The owner's say over an account that goes quiet (quiet.mjs).
586
574
  const quiet = await routeQuietApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod, apiPreflight });
587
575
  if (quiet) return withApiCors(quiet);
588
- // The owner's app saying it is open, and taking what was held (held-mail.mjs).
589
- const here = await routeHereApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod, apiPreflight, provedOwner })
590
- || await routeKeeperApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod, apiPreflight, provedOwner });
576
+ // The owner's app saying it is open, taking what was held, and naming what to push (held-mail.mjs).
577
+ const owned = { j, verifyPodToken, webidUnderPod, apiPreflight, provedOwner };
578
+ const here = await routeHereApi(request, pathname, ctx, owned) || await routeKeeperApi(request, pathname, ctx, owned) || await routePushApi(request, pathname, ctx, owned);
591
579
  if (here) return withApiCors(here);
592
580
 
593
581
  // The relay: the front sends requests a browser has already signed. A page
@@ -604,10 +592,9 @@ async function route(request, ctx) {
604
592
  const handle = String(body.handle || '').toLowerCase();
605
593
  const rec = await ctx.lookup(handle);
606
594
  if (!rec) { console.log(`relay: no account named ${handle}`); return withApiCors(j(404, { error: 'no such account' })); }
607
- const webid = await verifyPodToken(request, pathname, ctx.verifier);
595
+ const webid = await verifyPodToken(request, pathname, ctx.verifier, { only: ownsRow(rec) });
608
596
  if (!webid) { console.log(`relay @${handle}: no usable pod token`); return withApiCors(j(401, { error: 'a Solid-OIDC token proving the pod is required' })); }
609
- const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
610
- if (!owner) { console.log(`relay @${handle}: token is for ${webid}, not this account's pod`); return withApiCors(j(403, { error: "the token proves a different pod than this account's" })); }
597
+ if (!ownsRow(rec)(webid)) { console.log(`relay @${handle}: token is for ${webid}, not this account's pod`); return withApiCors(j(403, { error: "the token proves a different pod than this account's" })); }
611
598
  const items = Array.isArray(body.requests) ? body.requests : [];
612
599
  if (!items.length) return withApiCors(j(400, { error: 'requests must be a non-empty list' }));
613
600
  // Acting through the relay is being here. Hourly at most; see noteOpened.
@@ -678,6 +665,16 @@ async function route(request, ctx) {
678
665
  if (/(^|\/)\.internal(\/|$)/u.test(podUrl.pathname) || /(^|\/)\.\.(\/|$)/u.test(podUrl.pathname)) {
679
666
  return j(403, { error: 'that is not a pod address' });
680
667
  }
668
+ // A server that runs identities runs them for its own pods only, and says
669
+ // which those are. Asked before anything is fetched: the checks below read
670
+ // the addresses the request names, and a stranger names them.
671
+ if (ctx.agentControl.serves) {
672
+ if (!await ctx.agentControl.serves(podBase)) return j(403, { error: 'that pod is not on this server' });
673
+ const claimed = claimedWebId(request);
674
+ if (claimed && !await ctx.agentControl.serves(claimed)) {
675
+ return j(403, { error: 'the token is for a WebID on another server' });
676
+ }
677
+ }
681
678
  // The pod's own server names its owner when it can, and where it does that
682
679
  // is the proof. Where it does not, the WebID must live in EXACTLY this pod
683
680
  // — its own pod base equal to podBase, not merely starting with it, which on
@@ -758,7 +755,7 @@ async function route(request, ctx) {
758
755
  // the pod (and its login) without a lookup only the host could answer.
759
756
  const podActor = rec.inboxOnly ? [] : [rec.podHome + 'ap/actor'];
760
757
  const out = j(200, jrd({ handle: m[1], host: ctx.host, actor: rec.actorUrl, aliases: podActor,
761
- page: profilePageOf(rec), subscribe: subscribeTemplate(ctx.frontOrigin) }),
758
+ page: profilePageOf(rec), subscribe: subscribeTemplate(ctx.frontOrigin), avatar: rec.icon || null }),
762
759
  'application/jrd+json');
763
760
  // RFC 7033: a JRD is read from any origin.
764
761
  out.headers['access-control-allow-origin'] = '*';
@@ -112,7 +112,10 @@ export function asSigned(request, bytes, origin) {
112
112
  }
113
113
 
114
114
  // `origin` is the door's own, the one senders address (see asSigned).
115
- export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES, paused = false, origin = null } = {}) {
115
+ // `admit(verified)`, when given, says whether the inbox has room for this
116
+ // delivery now; one without room is answered 503 and written nowhere, so its
117
+ // sender tries again later. Without it every delivery that passes is written.
118
+ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES, paused = false, origin = null, admit = null } = {}) {
116
119
  // The body, once, as the bytes the sender digested; the text of it is what
117
120
  // is filed and hashed, as before.
118
121
  let bytes, raw;
@@ -157,6 +160,8 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
157
160
  failed = `signature ${v.why} (${v.method}); host ${request.headers.get('host') || 'none'}${fwd ? ` forwarded-for ${fwd}` : ''}, date ${has('date')}, digest ${has('digest') === 'yes' || has('content-digest') === 'yes' ? 'yes' : 'no'}; ${params.slice(0, 300)}`;
158
161
  }
159
162
 
163
+ if (admit && !admit(v.verified === true)) return { status: 503, reason: 'inbox full — try later' };
164
+
160
165
  // No per-user secret on record = no receipt: the delivery still filters and
161
166
  // forwards, it just reads as unverified at the drain — never a failure.
162
167
  const receipt = ident.hmacSecret
@@ -213,3 +213,26 @@ export async function routeHereApi(request, pathname, ctx, deps) {
213
213
  // `hold`: with it off, a browser working from a copy goes back to the pod.
214
214
  return j(200, { ok: true, handle, flushed, hold: holdOn(ctx) });
215
215
  }
216
+
217
+ // FediPod open in a browser reads its own mail, so nothing is held for the
218
+ // door to push: it names the notifications it made, and they are pushed to the
219
+ // phones and browsers its owner signed up (masto-gateway.mjs: pushMade).
220
+ // Proved like /api/here. Returns a response, or null for other paths.
221
+ const NOTIFICATION_ID = /^[0-9a-f]{16}$/u;
222
+ export async function routePushApi(request, pathname, ctx, deps) {
223
+ if (pathname !== '/api/push') return null;
224
+ const { j } = deps;
225
+ if (request.method === 'OPTIONS') return deps.apiPreflight();
226
+ if (request.method !== 'POST') return null;
227
+ let body;
228
+ try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
229
+ const handle = String(body.handle || '').toLowerCase();
230
+ const rec = await ctx.lookup(handle);
231
+ if (!rec) return j(404, { error: 'no such account' });
232
+ const who = await deps.provedOwner(request, pathname, ctx, rec, deps);
233
+ if (who.error) return who.error;
234
+ const ids = [...new Set((Array.isArray(body.ids) ? body.ids : []).filter((id) => NOTIFICATION_ID.test(String(id))))].slice(0, 50);
235
+ if (!ids.length || !ctx.startPush || !await ctx.pushWanted?.(handle).catch(() => false)) return j(200, { ok: true, handle, pushing: 0 });
236
+ await ctx.startPush(handle, { ids }).catch((e) => console.log(`push @${handle}: not started: ${e?.message || e}`));
237
+ return j(200, { ok: true, handle, pushing: ids.length });
238
+ }
@@ -29,7 +29,10 @@
29
29
  // keeps for all its accounts, made the first time it is needed. When the door
30
30
  // holds a delivery that becomes a notification (gateway-core: notifies), it
31
31
  // starts a short run (push-background.mjs, pushHeld below) that reads the held
32
- // mail into the copy and pushes each notification it makes.
32
+ // mail into the copy and pushes each notification it makes. While FediPod is
33
+ // open in a browser, it reads the mail itself and names the notifications it
34
+ // made (held-mail.mjs: /api/push); those are pushed from the account as it
35
+ // reads (pushMade below).
33
36
  import crypto from 'node:crypto';
34
37
  import { MastoApi } from '../client/masto/index.mjs';
35
38
  import { instanceConfig } from '../client/masto/instance.mjs';
@@ -49,7 +52,10 @@ import { noteOpened } from './quiet.mjs';
49
52
  import { Push } from '../client/webpush.mjs';
50
53
  import webpush from 'web-push';
51
54
 
55
+ // An app stays signed in for ninety days from its last use; the mark of use
56
+ // moves at most once a day.
52
57
  const TOKEN_TTL_MS = 90 * 86400_000;
58
+ const TOKEN_USE_MARK_MS = 86400_000;
53
59
  const CODE_TTL_MS = 10 * 60_000;
54
60
  const MAIL_EVERY_MS = 30_000;
55
61
  const SIGNIN_PAGE = '/app-signin/';
@@ -194,8 +200,11 @@ export async function vapidKeys(kv) {
194
200
  }
195
201
 
196
202
  class GatewayPush extends Push {
197
- constructor({ keys, ...o }) { super(o); this.keys = keys; }
203
+ constructor({ keys, keep = true, ...o }) { super(o); this.keys = keys; this.keep = keep; }
198
204
  vapid() { return this.keys; }
205
+ // Pushing from an account only read: a sign-up the push service has dropped
206
+ // is let go by the next push that acts on the account.
207
+ save(s) { if (this.keep) super.save(s); }
199
208
  }
200
209
 
201
210
  // ---- the account behind a token ----
@@ -282,10 +291,10 @@ function readingAgent(at, store, acting, log) {
282
291
  // contact address push services accept (https or mailto).
283
292
  const pushContact = (ctx) => (/^https:\/\//u.test(ctx.frontOrigin || '') ? `${ctx.frontOrigin.replace(/\/$/u, '')}/` : `mailto:postmaster@${ctx.host || 'localhost'}`);
284
293
 
285
- function facade(agent, token, log, keys = null, contact = null) {
294
+ function facade(agent, token, log, keys = null, contact = null, { keep = true } = {}) {
286
295
  const api = new MastoApi({ agent, log, scheme: 'https', streaming: false, webPush: !!keys, scheduling: true, allowed: noAuthorities });
287
296
  api.tokenOf = () => token;
288
- if (keys) api._push = new GatewayPush({ store: agent.store, keys, log, subject: () => contact });
297
+ if (keys) api._push = new GatewayPush({ store: agent.store, keys, log, subject: () => contact, keep });
289
298
  return api;
290
299
  }
291
300
 
@@ -390,6 +399,40 @@ export async function pushHeld(ctx, handle, rec, { log = console.log } = {}) {
390
399
  return n;
391
400
  }
392
401
 
402
+ /**
403
+ * For FediPod open in a browser, which read mail that went straight to the pod
404
+ * and names the notifications it made: each is read from the account as it is
405
+ * now, without acting, and pushed once, while it is new. One that came from
406
+ * another network (Bluesky, a connected account) is not pushed, as it is not
407
+ * while FediPod is closed.
408
+ */
409
+ const PUSH_FRESH_MS = 15 * 60_000;
410
+ const PUSHED_KEPT = 200;
411
+ export async function pushMade(ctx, handle, rec, ids, { log = console.log, now = Date.now() } = {}) {
412
+ if (!ids?.length || !await pushWanted(ctx, handle)) return 0;
413
+ const key = `pushed/${handle}`;
414
+ const done = (await getJson(ctx.mastoKv, key)) || [];
415
+ const want = new Set(ids.filter((id) => !done.includes(id)));
416
+ if (!want.size) return 0;
417
+ await waitOutClosing(ctx.copyKv, handle);
418
+ const at = await reachAccount(ctx, handle, rec, { log });
419
+ if (at.skipped) return 0;
420
+ const store = await readingStore(ctx, handle, at, log);
421
+ const agent = readingAgent(at, store, async () => { throw new Error('only reading'); }, log);
422
+ if (!agent) return 0;
423
+ // On the pod, a rewritten document leaves the listing as it was: what the
424
+ // push reads is asked for again, if it changed.
425
+ if (!at.inCopy) await store.refresh(['notifications.json', 'actors.json', 'statuses.json', 'webpush.json'], { ifChanged: true });
426
+ const fresh = store.getNotifications()
427
+ .filter((n) => want.has(n.id) && !n.bsky && !n.via && now - Date.parse(n.at) < PUSH_FRESH_MS);
428
+ if (!fresh.length) return 0;
429
+ const api = facade(agent, null, log, await vapidKeys(ctx.mastoKv), pushContact(ctx), { keep: false });
430
+ for (const n of fresh) await api.pushNotify(n).catch((e) => log(`push @${handle}: ${e.message}`));
431
+ await ctx.mastoKv.set(key, JSON.stringify([...fresh.map((n) => n.id), ...done].slice(0, PUSHED_KEPT)));
432
+ log(`@${handle}: ${fresh.length} made in FediPod pushed`);
433
+ return fresh.length;
434
+ }
435
+
393
436
  // A request from an app, answered by the account's facade over its copy, or
394
437
  // with the hold off, over the pod.
395
438
  async function answerForAccount(request, url, ctx, tok, rec, log, tokenHash) {
@@ -562,12 +605,14 @@ async function route(request, pathname, ctx, deps, log) {
562
605
 
563
606
  // The pod sign-in, proved: a code for the app.
564
607
  if (pathname === '/api/authorize' && request.method === 'POST') {
565
- const webid = await deps.verifyPodToken(request, pathname, ctx.verifier);
566
- if (!webid) return json(401, { error: 'sign in at your pod first' });
567
608
  const body = await request.clone().json().catch(() => ({}));
568
609
  const found = await accountFor(ctx, body.address, host);
569
610
  if (found.error) return json(found.error[0], { error: found.error[1] });
570
611
  const { handle, rec } = found;
612
+ // A token for anyone but this account's owner is refused before it is
613
+ // checked: checking fetches the addresses it names.
614
+ const webid = await deps.verifyPodToken(request, pathname, ctx.verifier, { only: (w) => w === rec.webId });
615
+ if (!webid) return json(401, { error: 'sign in at your pod first' });
571
616
  if (keptBefore(ctx, rec)) return json(503, { error: MOVING });
572
617
  if (!usable(ctx, rec)) return json(409, { error: notReady(rec) });
573
618
  if (rec.webId !== webid) return json(403, { error: `you signed in as ${webid}, and this account belongs to ${rec.webId}` });
@@ -635,13 +680,15 @@ async function route(request, pathname, ctx, deps, log) {
635
680
  const bearer = /^Bearer (.+)$/u.exec(request.headers.get('authorization') || '')?.[1];
636
681
  const tokenHash = bearer ? sha(bearer) : null;
637
682
  const tok = tokenHash ? await getJson(kv, `token/${tokenHash}`) : null;
638
- if (!tok || Date.now() - tok.at > TOKEN_TTL_MS) {
683
+ const lastUse = tok ? tok.usedAt || tok.at : 0;
684
+ if (!tok || Date.now() - lastUse > TOKEN_TTL_MS) {
639
685
  // A page of this site asking with a token the gateway never gave out is
640
686
  // FediPod's own client in a browser whose worker is not answering yet:
641
687
  // told to try again, not that it is signed out.
642
688
  if (request.headers.get('sec-fetch-site') === 'same-origin') return json(503, { error: 'FediPod is starting in this browser; try again' });
643
689
  return json(401, { error: 'The access token is invalid' });
644
690
  }
691
+ if (Date.now() - lastUse > TOKEN_USE_MARK_MS) await putJson(kv, `token/${tokenHash}`, { ...tok, usedAt: Date.now() }).catch(() => {});
645
692
  if (pathname === '/api/v1/apps/verify_credentials') {
646
693
  const app = await appOf(kv, tok.clientId);
647
694
  return json(200, { name: app?.name || 'an app', website: app?.website || null, scopes: String(tok.scope).split(/\s+/u) });
@@ -49,7 +49,7 @@ export async function routeNoticesApi(request, pathname, ctx, { j, verifyPodToke
49
49
  }
50
50
  if (request.method !== 'POST') return { status: 405, headers: {}, body: '' };
51
51
  if (!ctx.putNotice || !ctx.deleteNotice || !ctx.adminWebId) return j(501, { error: 'this front keeps no notices' });
52
- const webid = await verifyPodToken(request, pathname, ctx.verifier);
52
+ const webid = await verifyPodToken(request, pathname, ctx.verifier, { only: (w) => w === ctx.adminWebId });
53
53
  if (!webid) return j(401, { error: 'a Solid-OIDC token is required' });
54
54
  if (webid !== ctx.adminWebId) return j(403, { error: 'that WebID is not the admin of this front' });
55
55
  let body;
@@ -11,10 +11,10 @@ export async function ownerPostAtDoor(request, pathname, ctx, rec, handle, { cor
11
11
  const json = (status, obj, extra = {}) => ({ status,
12
12
  headers: { ...cors, 'content-type': 'application/json', 'cache-control': 'no-store', ...extra },
13
13
  body: JSON.stringify(obj) });
14
- const webid = await verifyPodToken(request, pathname, ctx.verifier);
14
+ const isOwner = (w) => (rec.webId ? w === rec.webId : webidUnderPod(w, rec.podHome));
15
+ const webid = await verifyPodToken(request, pathname, ctx.verifier, { only: isOwner });
15
16
  if (!webid) return json(401, { error: 'a Solid-OIDC token proving this account\'s owner is required' });
16
- const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
17
- if (!owner) return json(403, { error: 'this outbox belongs to its owner alone' });
17
+ if (!isOwner(webid)) return json(403, { error: 'this outbox belongs to its owner alone' });
18
18
  const onPod = (u) => u.replace(rec.actorUrl.replace(/ap\/actor$/u, ''), rec.podHome);
19
19
  const { status, reason, location, object } = await handleOwnerPost(request, identFor(rec),
20
20
  { podPut: (u, b, ct) => ctx.podPut(handle, u, b, ct), ownerWebId: webid,
@@ -17,6 +17,7 @@ import { verifyHttpSignature, makeSafeLoader } from './httpsig.mjs';
17
17
  import { asSigned } from './gateway-core.mjs';
18
18
  import { senderKeys } from './caches.mjs';
19
19
  import { keptNow } from './copy.mjs';
20
+ import { sameOrigin } from '../core/intake/activity.mjs';
20
21
 
21
22
  const AP_CT = 'application/activity+json';
22
23
  const PUBLIC_NAMES = new Set(['https://www.w3.org/ns/activitystreams#Public', 'as:Public', 'Public']);
@@ -51,7 +52,12 @@ export async function signedReader(request, { fetchImpl = fetch, origin = null }
51
52
  const v = await verifyHttpSignature(asSigned(request, new Uint8Array(0), origin), {
52
53
  documentLoader: makeSafeLoader({ fetchImpl }), keyCache: senderKeys,
53
54
  });
54
- return v.verified && typeof v.actor === 'string' ? { actor: v.actor, reason: null } : { actor: null, reason: v.reason };
55
+ if (!v.verified || typeof v.actor !== 'string') return { actor: null, reason: v.reason };
56
+ // A key document names its owner, and anyone can publish one naming anyone.
57
+ // Only a key on the owner's own server speaks for them — the drain's rule too
58
+ // (receiptVouchesFor in lib/core/intake/verify.mjs).
59
+ if (!sameOrigin(v.keyId, v.actor)) return { actor: null, reason: 'key-not-on-signer-origin' };
60
+ return { actor: v.actor, reason: null };
55
61
  }
56
62
 
57
63
  // Refused reads are told nothing, and never held at the edge.
@@ -63,6 +63,10 @@ export async function noteReceived(ctx, key, rec, bytes) {
63
63
  .catch((e) => console.log(`front @${key}: count not written: ${e?.message || e}`));
64
64
  }
65
65
 
66
+ // The account's picture as its owner's browser names it, for the address's
67
+ // WebFinger answer (front-core.mjs): an http(s) address, or none.
68
+ const pictureOf = (v) => (typeof v === 'string' && v.length <= 2048 && /^https?:\/\/\S+$/u.test(v) ? v : null);
69
+
66
70
  // The owner is here. `always` is a sign-in; the relay stamps at most hourly,
67
71
  // so a busy hour of posting is one write. The count that went with the old
68
72
  // stamp is dropped: it is over, and a store should not keep one per hour.
@@ -110,10 +114,10 @@ export const closedAnswer = (headers = {}, why = 'this address is closed') => ({
110
114
  // WebID is the row's, or lives on the row's pod for a row from before WebIDs
111
115
  // were recorded.
112
116
  export async function provedOwner(request, pathname, ctx, rec, { j, verifyPodToken, webidUnderPod }) {
113
- const webid = await verifyPodToken(request, pathname, ctx.verifier);
117
+ const isOwner = (w) => (rec.webId ? w === rec.webId : webidUnderPod(w, rec.podHome));
118
+ const webid = await verifyPodToken(request, pathname, ctx.verifier, { only: isOwner });
114
119
  if (!webid) return { error: j(401, { error: 'a Solid-OIDC token proving the pod is required' }) };
115
- const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
116
- if (!owner) return { error: j(403, { error: "the token proves a different pod than this account's" }) };
120
+ if (!isOwner(webid)) return { error: j(403, { error: "the token proves a different pod than this account's" }) };
117
121
  return { webid };
118
122
  }
119
123
 
@@ -142,6 +146,12 @@ export async function routeQuietApi(request, pathname, ctx, deps) {
142
146
  if (who.error) return who.error;
143
147
  const before = await closedState(ctx, handle, rec);
144
148
  if (before.closed) return j(410, { error: 'this address is closed', closedAt: before.closedAt, closedBy: before.closedBy });
149
+ // The picture rides on the same write as the stamp. Anything that is not
150
+ // an address, or null, is not a picture, and changes nothing.
151
+ if ('icon' in body && (body.icon === null || pictureOf(body.icon)) && pictureOf(body.icon) !== (rec.icon || null)) {
152
+ const { icon: _was, ...rest } = rec;
153
+ rec = pictureOf(body.icon) ? { ...rest, icon: pictureOf(body.icon) } : rest;
154
+ }
145
155
  rec = await noteOpened(ctx, handle, rec, { always: true });
146
156
  // Whom the app would name if its owner lets the gateway act for them while
147
157
  // it is closed, and whether they have (held-mail.mjs, keeper.mjs); and
@@ -162,8 +162,6 @@ export async function routeStateApi(request, pathname, ctx, deps) {
162
162
 
163
163
  if (pathname === '/api/state/open') {
164
164
  if (request.method !== 'POST') return j(405, { error: 'POST' });
165
- const webid = await deps.verifyPodToken(request, pathname, ctx.verifier);
166
- if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
167
165
  let body = {};
168
166
  try { body = JSON.parse((await request.clone().text()) || '{}'); } catch { return j(400, { error: 'bad JSON' }); }
169
167
  // The account: named, or the one browser account kept here for this WebID.
@@ -171,8 +169,14 @@ export async function routeStateApi(request, pathname, ctx, deps) {
171
169
  // Read fresh: FediPod asks this the moment it has turned keeping on, and a
172
170
  // row read a minute earlier would still say the account is not kept.
173
171
  let rec = handle ? await (ctx.lookupFresh || ctx.lookup)(handle) : null;
172
+ // A token for somebody with no account here is refused before it is
173
+ // checked: checking fetches the addresses it names.
174
+ const dir = handle ? {} : (await ctx.listDirectory?.() || {});
175
+ const only = handle ? (w) => !!rec && w === rec.webId : (w) => Object.values(dir).some((r) => r?.webId === w);
176
+ const webid = await deps.verifyPodToken(request, pathname, ctx.verifier, { only });
177
+ if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
174
178
  if (!handle) {
175
- const rows = Object.entries(await ctx.listDirectory?.() || {})
179
+ const rows = Object.entries(dir)
176
180
  .filter(([, r]) => r?.webId === webid && r.openedAt && r.keeper && !r.movedTo && !r.closedAt);
177
181
  if (rows.length > 1) return j(409, { error: 'more than one account here for this sign-in; name one', handles: rows.map(([h]) => h) });
178
182
  if (rows.length === 1) [[handle, rec]] = rows;
@@ -5,12 +5,74 @@
5
5
  // the claim decides nothing but the address. Verifying it here would let
6
6
  // anyone make the front fetch signing keys from a server they name, on every
7
7
  // read.
8
+ //
9
+ // And checking one: verifyPodToken, which the front's routes share.
8
10
 
9
- export function claimedWebId(request) {
11
+ import { podTokenVerifier } from './caches.mjs';
12
+ import { namesPrivateAddress, safeFetch, readCapped } from '../shared/safefetch.mjs';
13
+
14
+ function claimsOf(request) {
10
15
  const token = /^(?:DPoP|Bearer)\s+([\w-]+)\.([\w-]+)\./u.exec(request.headers.get('authorization') || '');
11
16
  if (!token) return null;
17
+ try { return JSON.parse(Buffer.from(token[2], 'base64url').toString('utf8')); } catch { return null; }
18
+ }
19
+
20
+ export function claimedWebId(request) {
21
+ const webid = claimsOf(request)?.webid;
22
+ return typeof webid === 'string' ? webid : null;
23
+ }
24
+
25
+ // Whether checking this token would stay off private addresses: the WebID it
26
+ // names, its identity provider, and the key set that provider names. A token
27
+ // the check cannot read fetches nothing. A provider whose settings cannot be
28
+ // read here, by a fetch that refuses private addresses at every redirect, is
29
+ // refused: the check's own fetch follows redirects anywhere.
30
+ async function fetchesArePublic(request) {
31
+ const c = claimsOf(request);
32
+ if (!c) return true;
33
+ if (typeof c.webid === 'string' && await namesPrivateAddress(c.webid)) return false;
34
+ if (typeof c.iss !== 'string') return true;
35
+ if (await namesPrivateAddress(c.iss)) return false;
36
+ let jwks = null;
37
+ try {
38
+ const res = await safeFetch(`${c.iss.replace(/\/$/u, '')}/.well-known/openid-configuration`, { headers: { accept: 'application/json' } });
39
+ jwks = JSON.parse(await readCapped(res, 64 * 1024))?.jwks_uri;
40
+ } catch { return false; }
41
+ return !(typeof jwks === 'string' && await namesPrivateAddress(jwks));
42
+ }
43
+
44
+ // Verify a Solid-OIDC token (DPoP-bound) and return its WebID, or null. The
45
+ // verifier is injected so tests stub it; in production it is the same library
46
+ // the agent's own C2S auth uses.
47
+ // Checking fetches the WebID and the identity provider the token names, and a
48
+ // stranger names them. `only`, when the caller knows whose token it wants,
49
+ // refuses a token naming anyone else before that; `publicOnly`, where any
50
+ // token may arrive, refuses one whose check would reach a private address.
51
+ export async function verifyPodToken(request, pathname, verifier, { only = null, publicOnly = false } = {}) {
52
+ const authz = request.headers.get('authorization');
53
+ if (!authz) return null;
54
+ const claimed = claimedWebId(request);
55
+ if (only && claimed && !only(claimed)) {
56
+ console.log(`front: pod token for somebody else refused unchecked on ${pathname}`);
57
+ return null;
58
+ }
59
+ if (publicOnly && !await fetchesArePublic(request)) {
60
+ console.log(`front: pod token naming a private address refused unchecked on ${pathname}`);
61
+ return null;
62
+ }
12
63
  try {
13
- const claims = JSON.parse(Buffer.from(token[2], 'base64url').toString('utf8'));
14
- return typeof claims?.webid === 'string' ? claims.webid : null;
15
- } catch { return null; }
64
+ const dpop = request.headers.get('dpop');
65
+ // Solid-OIDC binds the token to a key the client proves on every request;
66
+ // a token shown without the proof is one anyone who saw it could show.
67
+ if (!dpop) { console.log(`front: pod token without a DPoP proof refused on ${pathname}`); return null; }
68
+ const v = verifier || await podTokenVerifier();
69
+ const url = request.url;
70
+ const { webid } = await v(authz, { header: dpop, method: request.method, url });
71
+ return webid || null;
72
+ } catch (e) {
73
+ // Said aloud: a token the front will not take is otherwise a bare 401 to
74
+ // the caller and nothing at all here.
75
+ console.log(`front: pod token refused on ${pathname}: ${e?.message || e}`);
76
+ return null;
77
+ }
16
78
  }
package/lib/pod/actor.mjs CHANGED
@@ -73,6 +73,11 @@ export function linkInWebIdProfile(pod, { actorUrl, accountName, kind = 'person'
73
73
  return pod.linkAccountInProfile({ actorUrl, accountName, kind, outbox });
74
74
  }
75
75
 
76
+ /** The same link taken out again, for an account retired for good. */
77
+ export async function unlinkInWebIdProfile(pod, { actorUrl, outbox = null }) {
78
+ return pod.unlinkAccountInProfile({ actorUrl, outbox });
79
+ }
80
+
76
81
  // ---- anyone at all ----
77
82
 
78
83
  /**
@@ -534,6 +534,34 @@ export class PodTransport {
534
534
  return true;
535
535
  }
536
536
 
537
+ /**
538
+ * The account taken out of the WebID profile again, as linkAccountInProfile
539
+ * wrote it: the foaf:account link, what the profile says of the actor, and
540
+ * `outbox` when it is the one named. From every document of the profile that
541
+ * holds any of it, each write checked. Returns how many documents changed.
542
+ */
543
+ async unlinkAccountInProfile({ actorUrl, outbox = null }) {
544
+ const me = $rdf.sym(this.webId);
545
+ const actor = $rdf.sym(actorUrl);
546
+ let changed = 0;
547
+ for (const d of await this.profileDocs(podBaseOfWebId(this.webId))) {
548
+ if (!d.g) continue;
549
+ const gone = [
550
+ ...d.g.statementsMatching(me, FOAF('account'), actor),
551
+ ...d.g.statementsMatching(actor, RDF('type'), FOAF('OnlineAccount')),
552
+ ...d.g.statementsMatching(actor, RDF('type'), AS('Person')),
553
+ ...d.g.statementsMatching(actor, RDF('type'), AS('Group')),
554
+ ...d.g.statementsMatching(actor, FOAF('accountName'), null),
555
+ ...(outbox ? d.g.statementsMatching(me, AS('outbox'), $rdf.sym(outbox)) : []),
556
+ ].map((st) => [st.subject, st.predicate, st.object]);
557
+ if (!gone.length) continue;
558
+ const status = await this.writeRdfChecked(d.url, d.g, { deletes: gone, mustDescribe: d.profile ? this.webId : null });
559
+ if (status >= 300) throw new Error(`not written: ${d.url} → ${status}`);
560
+ changed++;
561
+ }
562
+ return changed;
563
+ }
564
+
537
565
  /**
538
566
  * An N3 Patch of exactly these statements, or false when the pod will not
539
567
  * take one and the caller should write the document instead.
@@ -65,6 +65,29 @@ export async function register(pod, indexUrl, actorUrl) {
65
65
  return true;
66
66
  }
67
67
 
68
+ /**
69
+ * Take the account's registration out again. A registration that names other
70
+ * instances too keeps them and loses only this one. True when one was there.
71
+ */
72
+ export async function unregister(pod, indexUrl, actorUrl) {
73
+ const g = await pod.readRdf(indexUrl);
74
+ if (!g) return false;
75
+ const actor = pod.sym(actorUrl);
76
+ const instance = pod.sym(SOLID + 'instance');
77
+ const gone = [];
78
+ for (const reg of g.each(null, instance, actor)) {
79
+ if (!g.holds(reg, pod.sym(SOLID + 'forClass'), pod.sym(AS_ACTOR))) continue;
80
+ const others = g.each(reg, instance, null).filter((i) => i.value !== actorUrl);
81
+ for (const st of others.length ? g.statementsMatching(reg, instance, actor) : g.statementsMatching(reg, null, null)) {
82
+ gone.push([st.subject, st.predicate, st.object]);
83
+ }
84
+ }
85
+ if (!gone.length) return false;
86
+ const status = await pod.writeRdfChecked(indexUrl, g, { deletes: gone, mustDescribe: indexUrl });
87
+ if (status >= 300) throw new Error(`the type index at ${indexUrl} did not let the registration go (${status})`);
88
+ return true;
89
+ }
90
+
68
91
  /**
69
92
  * Make a public type index and name it from the profile. Only ever called
70
93
  * after the person has said yes. An index already at the usual place is kept,