fedipod 1.48.1 → 1.49.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.
@@ -8,6 +8,16 @@ import * as podMedia from '../../pod/media.mjs';
8
8
  import * as social from '../../core/social.mjs';
9
9
  import { readBody, readMultipart } from './body.mjs';
10
10
 
11
+ // What a profile picture or header may be: an image of a kind every client
12
+ // shows, stored as that type and named by it, never by the client's filename.
13
+ // Anything else, a web page above all, would be served from the pod's own
14
+ // address and run there when opened.
15
+ const PROFILE_IMAGE_EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'image/avif': 'avif' };
16
+ const profileImageType = (f) => {
17
+ const t = String(f?.contentType || '').split(';')[0].trim().toLowerCase();
18
+ return PROFILE_IMAGE_EXT[t] ? t : null;
19
+ };
20
+
11
21
  export async function handle(api, ctx) {
12
22
  const { req, res, pathname, url, send } = ctx; // eslint-disable-line no-unused-vars
13
23
 
@@ -38,13 +48,17 @@ export async function handle(api, ctx) {
38
48
  else form = await readBody(req);
39
49
 
40
50
  const cfg = { ...api.store.getConfig() };
51
+ for (const f of [ files.avatar, files.header ]) {
52
+ if (f?.data?.length && !profileImageType(f)) {
53
+ return send(422, { error: 'a profile picture or header must be a PNG, JPEG, GIF, WebP or AVIF image' });
54
+ }
55
+ }
41
56
  const putImage = async (f) => {
42
- const ext = (f.filename || '').includes('.')
43
- ? f.filename.split('.').pop().replace(/[^\w]/g, '') : 'bin';
44
- const slug = new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex') + '.' + ext;
57
+ const type = profileImageType(f);
58
+ const slug = new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex') + '.' + PROFILE_IMAGE_EXT[type];
45
59
  const url = api.urls.media + slug;
46
60
  await api.agent.publisher.ensureMediaContainer();
47
- await podMedia.write(api.agent.remote, url, f.data, f.contentType);
61
+ await podMedia.write(api.agent.remote, url, f.data, type);
48
62
  return url;
49
63
  };
50
64
 
@@ -84,6 +84,7 @@ export class MastoApi {
84
84
  store: this.store,
85
85
  subject: () => this.urls?.actor || 'https://localhost/',
86
86
  log: this.log,
87
+ liveKeys: () => new Set(this.tokens().map((t) => this._push.keyOf(t))),
87
88
  });
88
89
  return this._push;
89
90
  }
@@ -177,6 +178,7 @@ export class MastoApi {
177
178
  registerApp(...a) { return oauth.registerApp(this, ...a); }
178
179
  mintCode(...a) { return oauth.mintCode(this, ...a); }
179
180
  consumeCode(...a) { return oauth.consumeCode(this, ...a); }
181
+ peekCode(...a) { return oauth.peekCode(this, ...a); }
180
182
  tokenOf(...a) { return oauth.tokenOf(this, ...a); }
181
183
  authed(...a) { return oauth.authed(this, ...a); }
182
184
  redirectAllowed(...a) { return oauth.redirectAllowed(this, ...a); }
@@ -12,6 +12,10 @@ const AUTHZ_WINDOW_MS = 60_000;
12
12
  const AUTHZ_MAX_ATTEMPTS = 5;
13
13
  const CODE_TTL_MS = 5 * 60_000; // an authorization code is short-lived
14
14
  const MAX_APPS = 200; // registered third-party clients, capped
15
+ const MAX_REDIRECTS = 10; // return addresses one client may register
16
+ const MAX_REDIRECT_CHARS = 2048;
17
+ const REGISTRATION_WINDOW_MS = 60_000;
18
+ const MAX_REGISTRATIONS = 10; // registrations per window, from anyone
15
19
  const CLIENT_DOC_TTL_MS = 10 * 60_000; // how long a fetched client document is trusted
16
20
  const CLIENT_DOC_MAX_CACHED = 200; // ids remembered at once; oldest out
17
21
  const CLIENT_DOC_WINDOW_MS = 60_000;
@@ -42,35 +46,38 @@ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c =>
42
46
  const parseRedirects = (v) => (Array.isArray(v) ? v : String(v || '').split(/\s+/))
43
47
  .map(s => s.trim()).filter(Boolean);
44
48
 
49
+ // The account's Fediverse address, @handle@host, or null before it has one.
50
+ const ownAddress = (api) => {
51
+ const handle = api.store.getConfig()?.handle;
52
+ return handle && api.host ? `@${handle}@${api.host}` : null;
53
+ };
54
+
45
55
  // The sign-in page for an account whose owner signs in at their pod: no
46
56
  // password of ours exists, and none is asked for. The page carries the
47
57
  // client's request in its address, signs the owner in at the pod with the
48
58
  // fediverse-session 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 you will be sent back to <code>${escapeHtml(where)}</code>` : ''}.</p>`;
57
- }
59
+ function sendPodSigninPage(res, client, webId, mount = '', address = null) {
60
+ let where = '';
61
+ try { where = client?.redirect ? new URL(client.redirect).host : ''; } catch { /* oob or blank */ }
62
+ const app = escapeHtml(client?.name || where || 'this app');
63
+ const question = `Allow ${app} to access your ${address ? `${escapeHtml(address)} ` : ''}account?`;
58
64
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' });
59
65
  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>
66
+ <title>${question}</title>
61
67
  <style>:root{color-scheme:light dark;--heading:#1a4f8a;--btn:#3d6b35;--line:#9a9a9a}
62
68
  body{font:20px Arial,Helvetica,sans-serif;max-width:28rem;margin:12vh auto;padding:0 1rem}
63
69
  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}
70
+ h1,p{overflow-wrap:anywhere}
65
71
  button{font:inherit;padding:.6rem 1.1rem;background:var(--btn);color:#fff;border:none;border-radius:9px;cursor:pointer}
66
72
  button[hidden]{display:none}
73
+ #cancel{background:transparent;color:inherit;border:2px solid currentColor;margin-left:.6rem}
67
74
  #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>
75
+ #signin-status:empty{min-height:0;padding:0;border:0;margin:0}
76
+ @media (prefers-color-scheme:dark){:root{--heading:#7fb3e8;--btn:#4a7a40}}</style></head><body>
69
77
  <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>
78
+ <h1>${question}</h1>
79
+ <a id="webid" href="${escapeHtml(webId)}" hidden></a>
80
+ <p><button type="button" id="signin" hidden>Allow</button><button type="button" id="cancel" hidden>Cancel</button></p>
74
81
  <p id="signin-status" role="status" aria-live="polite"></p>
75
82
  </main>
76
83
  <script type="module" src="${escapeHtml(mount)}/oauth/session/signin.mjs"></script>
@@ -324,12 +331,53 @@ export function registerApp(api, { name, website, redirectUris, scopes }) {
324
331
  clientSecret: crypto.randomBytes(32).toString('base64url'),
325
332
  name: String(name || 'client').slice(0, 200),
326
333
  website: String(website || '').slice(0, 500),
327
- redirectUris, scopes: String(scopes || 'read'), createdAt: Date.now(),
334
+ redirectUris, scopes: String(scopes || 'read').slice(0, 500), createdAt: Date.now(),
335
+ // Set when the owner first lets it sign in (touchApp): a client nobody
336
+ // has used is the first to go when the list is full.
337
+ usedAt: null,
328
338
  };
329
- api.store.write('oauth-apps.json', [...api.apps(), app].slice(-MAX_APPS));
339
+ const kept = api.apps();
340
+ while (kept.length >= MAX_APPS) kept.splice(kept.indexOf(leastUsed(kept)), 1);
341
+ api.store.write('oauth-apps.json', [...kept, app]);
330
342
  return app;
331
343
  }
332
344
 
345
+ // The client to push out of a full list: one nobody has signed in with,
346
+ // oldest first; failing that, the one used longest ago. A client registered
347
+ // before `usedAt` existed counts as used when it was made, so a flood of new
348
+ // registrations cannot push out the apps the owner already has.
349
+ function leastUsed(list) {
350
+ const never = list.filter((a) => a.usedAt === null);
351
+ const when = (a) => a.usedAt ?? a.createdAt ?? 0;
352
+ return (never.length ? never : list).reduce((a, b) => (when(b) < when(a) ? b : a));
353
+ }
354
+
355
+ export function touchApp(api, clientId) {
356
+ const all = api.apps();
357
+ const app = all.find((a) => a.clientId === clientId);
358
+ if (!app) return;
359
+ app.usedAt = Date.now();
360
+ api.store.write('oauth-apps.json', all);
361
+ }
362
+
363
+ // Why a registration's return addresses cannot be kept, or null. Registering
364
+ // needs no sign-in, so its size is the part a stranger controls.
365
+ export function redirectsProblem(raw) {
366
+ if (Array.isArray(raw) && raw.some((v) => typeof v !== 'string')) return 'redirect_uris must be text';
367
+ const list = parseRedirects(raw);
368
+ if (list.length > MAX_REDIRECTS) return `at most ${MAX_REDIRECTS} redirect_uris`;
369
+ if (list.some((u) => u.length > MAX_REDIRECT_CHARS)) return `a redirect_uri is at most ${MAX_REDIRECT_CHARS} characters`;
370
+ return null;
371
+ }
372
+
373
+ export function registrationLimited(api) {
374
+ const now = Date.now();
375
+ api.appRegistrations = (api.appRegistrations || []).filter((t) => now - t < REGISTRATION_WINDOW_MS);
376
+ if (api.appRegistrations.length >= MAX_REGISTRATIONS) return true;
377
+ api.appRegistrations.push(now);
378
+ return false;
379
+ }
380
+
333
381
  // A short-lived, single-use authorization code for a registered client, kept
334
382
  // apart from masto-tokens.json so the code is NOT a bearer until it is
335
383
  // exchanged with the client secret.
@@ -348,6 +396,13 @@ export function mintCode(api, { clientId, redirectUri, scope, challenge = null,
348
396
  return code;
349
397
  }
350
398
 
399
+ // The live record for a code, left in place: the exchange checks it first,
400
+ // so a caller who cannot prove it does not use it up for the one who can.
401
+ export function peekCode(api, code) {
402
+ const now = Date.now();
403
+ return api.store.read('oauth-codes.json', []).find(c => c.code === code && now - c.createdAt < CODE_TTL_MS) || null;
404
+ }
405
+
351
406
  export function consumeCode(api, code) {
352
407
  const now = Date.now();
353
408
  const all = api.store.read('oauth-codes.json', []);
@@ -376,7 +431,11 @@ export function redirectAllowed(api, redirect) {
376
431
  try {
377
432
  const u = new URL(redirect);
378
433
  if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
379
- return api.allowed.has(u.host.toLowerCase());
434
+ if (!api.allowed.has(u.host.toLowerCase())) return false;
435
+ // A suffixed pod shares its host with other pods: its own addresses are
436
+ // those under its path, and the rest of the host is somebody else's.
437
+ const mount = api.mount || '';
438
+ return !mount || u.pathname === mount || u.pathname.startsWith(`${mount}/`);
380
439
  } catch { return false; }
381
440
  }
382
441
 
@@ -398,6 +457,13 @@ export async function handle(api, ctx) {
398
457
  // authorize step becomes a real login form.
399
458
  if (pathname === '/api/v1/apps' && req.method === 'POST') {
400
459
  const body = await readBody(req);
460
+ const problem = redirectsProblem(body.redirect_uris);
461
+ if (problem) return send(422, { error: problem });
462
+ if (registrationLimited(api)) {
463
+ api.log('app registration refused: too many this minute');
464
+ return send(429, { error: 'too many apps registered just now — try again in a minute' },
465
+ { 'retry-after': String(REGISTRATION_WINDOW_MS / 1000) });
466
+ }
401
467
  const redirectUris = parseRedirects(body.redirect_uris);
402
468
  const app = api.registerApp({ name: body.client_name, website: body.website,
403
469
  redirectUris, scopes: body.scopes });
@@ -427,7 +493,7 @@ export async function handle(api, ctx) {
427
493
  // The way back from the pod's login: no client named, only the pod's
428
494
  // code. The page finishes the sign-in and returns to the client's request.
429
495
  if (req.method === 'GET' && !params.get('client_id') && params.get('code') && params.get('state') && ownerWebId) {
430
- return sendPodSigninPage(res, null, ownerWebId, api.mount || '');
496
+ return sendPodSigninPage(res, null, ownerWebId, api.mount || '', ownAddress(api));
431
497
  }
432
498
  const app = api.findApp(params.get('client_id') || '');
433
499
  // A client that published its own metadata document needs no
@@ -471,9 +537,15 @@ export async function handle(api, ctx) {
471
537
  // about a web page. Anything pointing elsewhere has to be approved by
472
538
  // somebody who knows the password.
473
539
  const client = { name: app?.name || doc?.name || null, redirect, scope: params.get('scope') || 'read' };
540
+ // A proof method named and not known is refused rather than read as the
541
+ // weakest one, whose proof is the challenge itself.
542
+ const challengeMethod = params.get('code_challenge_method');
543
+ if (params.get('code_challenge') && challengeMethod && challengeMethod !== 'S256' && challengeMethod !== 'plain') {
544
+ return send(400, { error: 'code_challenge_method must be S256 or plain' });
545
+ }
474
546
  if (external && !proved && !api.store.getConfig()?.uiPassword && !api.redirectAllowed(redirect)) {
475
547
  // 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 || '');
548
+ if (req.method === 'GET' && ownerWebId) return sendPodSigninPage(res, client, ownerWebId, api.mount || '', ownAddress(api));
477
549
  api.log(`authorize refused: no UI password, and "${redirect}" is not an address of this agent`);
478
550
  return send(403, {
479
551
  error: 'this client asks to be sent somewhere other than this agent, and no password is '
@@ -519,7 +591,7 @@ export async function handle(api, ctx) {
519
591
  return sendLoginForm(res, params, '', client);
520
592
  } else if (!proved && api.allowed && !api.allowed.isLocalRequest(req) && !api.throughDoor?.(req)) {
521
593
  // 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 || '');
594
+ if (req.method === 'GET' && ownerWebId) return sendPodSigninPage(res, client, ownerWebId, api.mount || '', ownAddress(api));
523
595
  // No password set, and this request did not come from this machine.
524
596
  //
525
597
  // The instant-authorize path is honest theatre on loopback: whoever can
@@ -543,6 +615,7 @@ export async function handle(api, ctx) {
543
615
  challenge: params.get('code_challenge') || null,
544
616
  challengeMethod: params.get('code_challenge_method') || null })
545
617
  : api.mintToken(client.scope);
618
+ if (app) touchApp(api, app.clientId);
546
619
  if (!redirect || redirect === 'urn:ietf:wg:oauth:2.0:oob') return send(200, { code });
547
620
  const target = new URL(redirect);
548
621
  target.searchParams.set('code', code);
@@ -563,7 +636,7 @@ export async function handle(api, ctx) {
563
636
  // challenge is the whole of its proof. The code carries the document's
564
637
  // URL as the client it was bound to.
565
638
  if (!app && body.code_verifier && /^https:\/\//iu.test(String(body.client_id || ''))) {
566
- const rec = api.consumeCode(body.code || '');
639
+ const rec = api.peekCode(body.code || '');
567
640
  if (!rec || rec.clientId !== body.client_id
568
641
  || (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
569
642
  api.log('token refused: code is not a live authorization for that client document');
@@ -573,6 +646,7 @@ export async function handle(api, ctx) {
573
646
  api.log('token refused: the verifier does not answer the challenge this code was made with');
574
647
  return send(400, { error: 'invalid_grant' });
575
648
  }
649
+ if (!api.consumeCode(body.code || '')) return send(400, { error: 'invalid_grant' });
576
650
  return send(200, { access_token: api.mintToken(rec.scope || 'read'), token_type: 'Bearer',
577
651
  scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
578
652
  ...(api.urls?.actor ? { activitypub_actor_id: api.urls.actor } : {}) });
@@ -582,7 +656,7 @@ export async function handle(api, ctx) {
582
656
  // challenge it presented at authorize (RFC 7636). Sending a verifier is
583
657
  // what says which of the two flows this is.
584
658
  if (app && body.code_verifier) {
585
- const rec = api.consumeCode(body.code || '');
659
+ const rec = api.peekCode(body.code || '');
586
660
  if (!rec || rec.clientId !== app.clientId || (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
587
661
  api.log('token refused: code is not a live authorization for this client');
588
662
  return send(400, { error: 'invalid_grant' });
@@ -593,6 +667,7 @@ export async function handle(api, ctx) {
593
667
  api.log('token refused: the verifier does not answer the challenge this code was made with');
594
668
  return send(400, { error: 'invalid_grant' });
595
669
  }
670
+ if (!api.consumeCode(body.code || '')) return send(400, { error: 'invalid_grant' });
596
671
  return send(200, { access_token: api.mintToken(rec.scope || 'read'), token_type: 'Bearer',
597
672
  scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
598
673
  ...(api.urls?.actor ? { activitypub_actor_id: api.urls.actor } : {}) });
@@ -602,7 +677,7 @@ export async function handle(api, ctx) {
602
677
  const known = Buffer.from(app.clientSecret);
603
678
  const okSecret = given.length === known.length && crypto.timingSafeEqual(given, known);
604
679
  if (!okSecret) { api.log('token refused: client secret mismatch'); return send(401, { error: 'invalid_client' }); }
605
- const rec = api.consumeCode(body.code || '');
680
+ const rec = api.peekCode(body.code || '');
606
681
  if (!rec || rec.clientId !== app.clientId || (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
607
682
  api.log('token refused: code is not a live authorization for this client');
608
683
  return send(400, { error: 'invalid_grant' });
@@ -613,6 +688,7 @@ export async function handle(api, ctx) {
613
688
  api.log('token refused: this code was made with a challenge and the verifier does not answer it');
614
689
  return send(400, { error: 'invalid_grant' });
615
690
  }
691
+ if (!api.consumeCode(body.code || '')) return send(400, { error: 'invalid_grant' });
616
692
  return send(200, { access_token: api.mintToken(rec.scope || 'read'), token_type: 'Bearer',
617
693
  scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
618
694
  // Which actor the token acts for. A Mastodon client ignores it; an
@@ -647,6 +723,8 @@ export async function handle(api, ctx) {
647
723
  if (gone) {
648
724
  const kept = api.tokenRecords().filter(r => r.token !== gone);
649
725
  api.store.write('masto-tokens.json', kept);
726
+ // Its notifications stop with it.
727
+ if (api.push.get(gone)) api.push.drop(gone);
650
728
  api.log('client token revoked');
651
729
  }
652
730
  return send(200, {});
@@ -8,6 +8,9 @@
8
8
  // The verifier is injected so offline tests stub it, and wrapped so the
9
9
  // library (CJS, older jose) can be replaced without touching any caller.
10
10
 
11
+ import { scopeAllows } from './masto/oauth.mjs';
12
+ import { claimedWebId } from '../gateway/token-claims.mjs';
13
+
11
14
  export function makeC2sAuth({ agent, masto = null, verifier = null, log = () => {}, scheme = null, mount = '' }) {
12
15
  let verify = verifier;
13
16
  const loadVerifier = async () => {
@@ -19,7 +22,14 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
19
22
  };
20
23
 
21
24
  return async function authenticate(req, pathname) {
22
- if (masto?.authed(req)) {
25
+ const bearer = masto?.tokenOf(req);
26
+ if (bearer) {
27
+ // The same permission the Mastodon routes ask of this token: a client
28
+ // allowed only to read does not post here either.
29
+ const need = req.method === 'GET' || req.method === 'HEAD' ? 'read' : 'write';
30
+ if (!scopeAllows(bearer.scope, need)) {
31
+ return { ok: false, status: 403, error: `this app was not allowed to ${need === 'read' ? 'read' : 'post'} for the account` };
32
+ }
23
33
  return { ok: true, webid: agent.remote?.webId || null, via: 'bearer' };
24
34
  }
25
35
  if (!req.headers.authorization) {
@@ -32,6 +42,12 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
32
42
  return { ok: false, status: 401, error: 'a Solid-OIDC token must come with its DPoP proof',
33
43
  headers: { 'www-authenticate': 'DPoP' } };
34
44
  }
45
+ // A token naming anyone but the owner is refused before it is checked:
46
+ // checking fetches the documents it names, and a stranger names them.
47
+ const claimed = claimedWebId({ headers: { get: (n) => req.headers[n] ?? null } });
48
+ if (claimed && claimed !== agent.remote?.webId) {
49
+ return { ok: false, status: 403, error: 'this account belongs to its owner alone' };
50
+ }
35
51
  let webid;
36
52
  try {
37
53
  const v = await loadVerifier();
@@ -48,8 +64,10 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
48
64
  { header: req.headers.dpop, method: req.method, url: htu },
49
65
  ));
50
66
  } catch (e) {
67
+ // The reason stays here: it can carry what was read from an address
68
+ // the token named.
51
69
  log(`c2s auth: token rejected — ${e.message}`);
52
- return { ok: false, status: 401, error: `token rejected: ${e.message}` };
70
+ return { ok: false, status: 401, error: 'token rejected' };
53
71
  }
54
72
  const owner = agent.remote?.webId;
55
73
  if (!owner || webid !== owner) {
@@ -7,10 +7,13 @@ import crypto from 'node:crypto';
7
7
  import webpush from 'web-push';
8
8
 
9
9
  export class Push {
10
- constructor({ store, subject, log = () => {} }) {
10
+ constructor({ store, subject, log = () => {}, liveKeys = null }) {
11
11
  this.store = store;
12
12
  this.subject = subject; // () => the actor URL, VAPID's contact
13
13
  this.log = log;
14
+ // () => the keys of the sign-ins still in force, where they are kept beside
15
+ // the subscriptions; without it no subscription is forgotten for its sign-in.
16
+ this.liveKeys = liveKeys;
14
17
  }
15
18
 
16
19
  state() { return this.store.read('webpush.json', { subs: {} }); }
@@ -68,14 +71,21 @@ export class Push {
68
71
 
69
72
  // Push one notification to every subscription whose alerts allow the type.
70
73
  // A push service answering 404/410 means the browser dropped the
71
- // subscription — it is removed rather than retried forever.
74
+ // subscription — it is removed rather than retried forever. With
75
+ // `liveKeys`, every subscription whose sign-in is no longer in force is
76
+ // forgotten first: an app signed out, expired or pushed off the list hears
77
+ // nothing more.
72
78
  async notify(n, payload) {
73
79
  const st = this.state();
80
+ let dropped = false;
81
+ const live = this.liveKeys?.() || null;
82
+ if (live) {
83
+ for (const key of Object.keys(st.subs)) if (!live.has(key)) { delete st.subs[key]; dropped = true; }
84
+ }
74
85
  const entries = Object.entries(st.subs);
75
- if (!entries.length) return;
86
+ if (!entries.length) { if (dropped) this.save(st); return; }
76
87
  const { publicKey, privateKey } = this.vapid();
77
88
  const details = { subject: this.subject(), publicKey, privateKey };
78
- let dropped = false;
79
89
  for (const [key, sub] of entries) {
80
90
  if (sub.alerts && sub.alerts[n.type] === false) continue;
81
91
  try {
@@ -29,6 +29,20 @@ const DEFAULTS = {
29
29
  intervalMin: 15,
30
30
  };
31
31
  const PER_TAG = 20; // statuses requested per tag per sweep
32
+ // One copy of each tag's timeline per process, for every account sweeping it:
33
+ // a server running many accounts asks the instance once per tag in this
34
+ // window, not once per account. Just under the shortest default interval.
35
+ const SHARED_TTL_MS = 12 * 60_000;
36
+ const sharedTimelines = new Map(); // `${instance}|${tag}` → { at, answer }
37
+ export function timelineOnce(key, load) {
38
+ const now = Date.now();
39
+ const held = sharedTimelines.get(key);
40
+ if (held && now - held.at < SHARED_TTL_MS) return held.answer;
41
+ const answer = load().catch((e) => { sharedTimelines.delete(key); throw e; });
42
+ sharedTimelines.set(key, { at: now, answer });
43
+ if (sharedTimelines.size > 500) sharedTimelines.delete(sharedTimelines.keys().next().value);
44
+ return answer;
45
+ }
32
46
  const MAX_NEW_PER_SWEEP = 8; // dereference budget per sweep — stay light
33
47
  // …and no one tag may take more than this much of it. Without a per-tag share
34
48
  // the budget was spent in list order: the first two or three busy tags used it
@@ -141,21 +155,27 @@ export class TagFeed {
141
155
  try {
142
156
  const { safeFetch, retryAfterMs, readCapped } = await import('../shared/safefetch.mjs');
143
157
  const url = `${instance}/api/v1/timelines/tag/${encodeURIComponent(tag)}?limit=${PER_TAG}`;
144
- const res = this.fetcher === globalThis.fetch
145
- ? await safeFetch(url, { headers: { accept: 'application/json' } })
146
- : await this.fetcher(url, { headers: { accept: 'application/json' } });
147
- if (res.status >= 400) {
158
+ const load = async () => {
159
+ const res = this.fetcher === globalThis.fetch
160
+ ? await safeFetch(url, { headers: { accept: 'application/json' } })
161
+ : await this.fetcher(url, { headers: { accept: 'application/json' } });
162
+ if (res.status >= 400) return { status: res.status, retryAfter: retryAfterMs(res), list: null };
163
+ // Somebody else's server, polled on our schedule: bounded, because
164
+ // res.json() would buffer a timeline of whatever size it sent.
165
+ return { status: res.status, list: JSON.parse(await readCapped(res, MAX_TIMELINE_BYTES)) };
166
+ };
167
+ // Shared only with the real network: a fetcher handed in is a test's own.
168
+ const got = this.fetcher === globalThis.fetch ? await timelineOnce(`${instance}|${tag}`, load) : await load();
169
+ if (got.status >= 400) {
148
170
  // This is somebody else's server, polled on OUR schedule, and it had
149
171
  // no way to ask us to stop: a 429 or a 503 logged a line and the next
150
172
  // sweep arrived on the same cadence regardless. One refusal now ends
151
173
  // the sweep — the remaining tags are on the same instance, so there
152
174
  // is nothing to be gained by asking them too.
153
- this._backOff(res.status, retryAfterMs(res));
175
+ this._backOff(got.status, got.retryAfter);
154
176
  return;
155
177
  }
156
- // Somebody else's server, polled on our schedule: bounded, because
157
- // res.json() would buffer a timeline of whatever size it sent.
158
- list = JSON.parse(await readCapped(res, MAX_TIMELINE_BYTES));
178
+ list = got.list;
159
179
  } catch (e) {
160
180
  // A network failure is the same signal, minus the courtesy.
161
181
  this.log(`tagfeed #${tag}: ${e.message}`);
@@ -25,7 +25,7 @@ import { AcctFeed } from '../connections/acctfeed.mjs';
25
25
  import { BskyFeed } from '../connections/bskyfeed.mjs';
26
26
  import { BskyGroup } from '../connections/bskygroup.mjs';
27
27
  import { Lease } from './lease.mjs';
28
- import { publishDue } from './scheduled.mjs';
28
+ import { publishDue, nextToPublish } from './scheduled.mjs';
29
29
  import { apUrls, assertionKeyId, publicHandle } from './wire.mjs';
30
30
  import { followActor, unfollowActor, resolveHandle } from './social.mjs';
31
31
  import * as podInbox from '../pod/inbox.mjs';
@@ -306,6 +306,9 @@ export class Agent {
306
306
  proofKeyId: assertionKeyId(this.urls),
307
307
  log: this.log, passive: this.viewer,
308
308
  onGone: () => this.publisher.publishCollections({ followers: true }),
309
+ // Inside a pod server, a long-lived process: several followers' servers
310
+ // at once rather than one after another.
311
+ parallel: this.embedded ? 6 : 1,
309
312
  });
310
313
  this.publisher = new Publisher({
311
314
  config, remote: this.remote, store: this.store,
@@ -317,6 +320,9 @@ export class Agent {
317
320
  // origin, so it can be advertised. Standalone it is on loopback, and
318
321
  // naming it in a world-readable actor would send clients nowhere.
319
322
  clientOrigin: this.embedded ? this.urls.base : null,
323
+ // There, a post is answered once it is on the pod and delivery carries
324
+ // on behind; a process that may be stopped any moment waits for it.
325
+ background: this.embedded,
320
326
  });
321
327
  // Intake is constructed even for viewers — its signed fetchAP powers
322
328
  // search/deref; start() (draining) is active-only.
@@ -553,11 +559,17 @@ export class Agent {
553
559
  this.tagfeed.start();
554
560
  this.startBsky();
555
561
  this.startAccts();
556
- // Scheduled posts and polls whose time is up: a 30s sweep does what has
557
- // come due (lib/core/scheduled.mjs).
562
+ // Scheduled posts and polls whose time is up: every 30s the clock is
563
+ // compared with the next one due, and only then is anything done
564
+ // (lib/core/scheduled.mjs).
558
565
  clearInterval(this.schedTimer);
566
+ let publishing = false;
559
567
  this.schedTimer = setInterval(() => {
560
- publishDue(this.store, this.publisher, this.log).catch(err => this.log(`scheduled: ${err.message}`));
568
+ const due = nextToPublish(this.store);
569
+ if (publishing || due == null || due > Date.now()) return;
570
+ publishing = true;
571
+ publishDue(this.store, this.publisher, this.log).catch(err => this.log(`scheduled: ${err.message}`))
572
+ .finally(() => { publishing = false; });
561
573
  }, 30_000);
562
574
  this.schedTimer.unref();
563
575
  // A CSV import interrupted by a restart or a handoff picks back up here.
@@ -66,7 +66,7 @@ export class Deliverer {
66
66
  // placing a post as it lands — which records what to send and leaves the
67
67
  // sending to the run it starts.
68
68
  constructor({ store, keyId, rsaPrivate, actorId = null, edPrivate = null, proofKeyId = null,
69
- log = console.log, passive = false, defer = false, onGone = null }) {
69
+ log = console.log, passive = false, defer = false, onGone = null, parallel = 1 }) {
70
70
  this.store = store;
71
71
  this.defer = defer;
72
72
  // Called after followers at a gone inbox are dropped: the agent republishes
@@ -81,7 +81,9 @@ export class Deliverer {
81
81
  // How many fresh deliveries one attempt may carry. The Node deliverer
82
82
  // opens a socket per recipient; the browser's relay deliverer sends a
83
83
  // list, and sets this to what the relay accepts.
84
- this.batchSize = 1;
84
+ this.batchSize = parallel;
85
+ // Sent side by side, up to that many at once, rather than one after another.
86
+ this.parallel = parallel;
85
87
  if (!passive) this.startQueue();
86
88
  }
87
89
 
@@ -184,6 +186,10 @@ export class Deliverer {
184
186
  // with the error deliverNow would have thrown. Here one at a time; the
185
187
  // relay deliverer sends the whole list in one call.
186
188
  async deliverManyNow(targets) {
189
+ if (this.parallel > 1) {
190
+ return Promise.all(targets.map((t) => this.deliverNow(t.inbox, t.activity)
191
+ .then(() => ({ ok: true }), (error) => ({ error }))));
192
+ }
187
193
  const out = [];
188
194
  for (const t of targets) {
189
195
  try { await this.deliverNow(t.inbox, t.activity); out.push({ ok: true }); }
@@ -40,6 +40,9 @@ const POLL_MS = 2 * 60_000; // fallback cadence when there is no push
40
40
  // With a live socket the poll is pure redundancy: it exists for the case where
41
41
  // push is down, so it slows right down while push is up.
42
42
  const POLL_PUSH_OK_MS = 10 * 60_000;
43
+ // Inside the pod server the store's own events wake the drain, and they are
44
+ // not lost the way a socket's are: the poll is only a backstop.
45
+ const POLL_IN_PROCESS_MS = 60 * 60_000;
43
46
  // A container that times out will time out again in two minutes, and each
44
47
  // attempt holds one of the pod's workers for the full timeout. Sweeping stops
45
48
  // for a while instead, doubling up to half an hour.
@@ -113,7 +116,8 @@ export class Intake {
113
116
  // what makes the fallback poll a slow one.
114
117
  _pollMs() {
115
118
  if (this.pollSeconds) return this.pollSeconds * 1000;
116
- return this.wsState === 'open' || this.wsState === 'in-process' ? POLL_PUSH_OK_MS : POLL_MS;
119
+ if (this.wsState === 'in-process') return POLL_IN_PROCESS_MS;
120
+ return this.wsState === 'open' ? POLL_PUSH_OK_MS : POLL_MS;
117
121
  }
118
122
 
119
123
  // `drainNow: false` and `subscribe: false` are for a browser worker the
@@ -121,6 +125,10 @@ export class Intake {
121
125
  async start({ drainNow = true, subscribe = true } = {}) {
122
126
  this.stopped = false; // restartable across demote/takeover cycles
123
127
  if (drainNow) await this.drain().catch(e => this.log(`drain: ${e.message}`));
128
+ // Embedded in the pod server, a notification socket back to that same
129
+ // server buys nothing — the store's own change events wake the drain.
130
+ // Settled before the first poll is timed, which reads it.
131
+ if (!this.push) this.wsState = 'in-process';
124
132
  const tick = () => {
125
133
  this.pollTimer = setTimeout(() => {
126
134
  this.drain().catch(e => this.log(`drain: ${e.message}`)).finally(() => { if (!this.stopped) tick(); });
@@ -128,10 +136,7 @@ export class Intake {
128
136
  this.pollTimer.unref?.();
129
137
  };
130
138
  tick();
131
- // Embedded in the pod server, a notification socket back to that same
132
- // server buys nothing — the store's own change events wake the drain.
133
- if (!this.push) this.wsState = 'in-process';
134
- else if (subscribe) this.subscribe().catch(e => this.log(`subscribe: ${e.message}`));
139
+ if (this.push && subscribe) this.subscribe().catch(e => this.log(`subscribe: ${e.message}`));
135
140
  }
136
141
 
137
142
  stop() { this.stopped = true; clearTimeout(this.pollTimer); clearTimeout(this.resubTimer); this.ws?.close(); }
@@ -35,6 +35,7 @@ import { recordPlace, forgetPlace } from '../place.mjs';
35
35
  export class Publisher {
36
36
  constructor({ config, remote, store, deliverer, publicKeyPem, assertionKey = null, log = console.log,
37
37
  probeFetch = null, resolveMention = null, resolveActor = null, clientOrigin = null,
38
+ background = false,
38
39
  }) {
39
40
  this.config = config;
40
41
  this.remote = remote;
@@ -48,6 +49,8 @@ export class Publisher {
48
49
  // stranger can reach. Null on a laptop, where the surface is on loopback
49
50
  // and advertising it to the world would name somewhere nobody can go.
50
51
  this.clientOrigin = clientOrigin;
52
+ // Whether a new post's delivery may carry on after it is answered.
53
+ this.background = background;
51
54
  // A fronted identity (config.gateway.frontActor) advertises its ids on a
52
55
  // shared domain; the map tells RemotePod where each writes on the pod.
53
56
  const publicBase = config.gateway?.frontActor
@@ -321,7 +321,11 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
321
321
  reply?.inbox,
322
322
  ...addressedInboxes,
323
323
  ].filter(Boolean))];
324
- await publisher.deliverer.deliverToAll(inboxes, create);
324
+ // The post is on the pod; where the process lives on, the app is answered
325
+ // now and delivery carries on behind.
326
+ const fanout = publisher.deliverer.deliverToAll(inboxes, create);
327
+ if (publisher.background) fanout.catch((e) => publisher.log(`delivering ${note.id}: ${e.message}`));
328
+ else await fanout;
325
329
  publisher.log(`note published: ${note.id} → ${inboxes.length} inbox(es)`);
326
330
  // The quoted author is asked after the note is out: their Accept names the
327
331
  // note, so it has to exist where they will look for it.
@@ -24,6 +24,18 @@ export async function publishDue(store, publisher, log = () => {}, now = Date.no
24
24
  return due.length;
25
25
  }
26
26
 
27
+ /**
28
+ * When the next scheduled post or poll end falls due, in milliseconds, or null.
29
+ * Cheap enough to ask every 30 seconds: the timeline is read, not copied.
30
+ */
31
+ export function nextToPublish(store) {
32
+ const at = [
33
+ ...store.getScheduled().map((e) => Date.parse(e.scheduledAt)),
34
+ ...(store.openPollEnds?.() || []),
35
+ ].filter(Number.isFinite);
36
+ return at.length ? Math.min(...at) : null;
37
+ }
38
+
27
39
  /**
28
40
  * When the next thing falls due, or null when nothing is waiting: a scheduled
29
41
  * post, a poll's end, or another try at a delivery that failed.