fedipod 1.49.0 → 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.
@@ -178,6 +178,7 @@ export class MastoApi {
178
178
  registerApp(...a) { return oauth.registerApp(this, ...a); }
179
179
  mintCode(...a) { return oauth.mintCode(this, ...a); }
180
180
  consumeCode(...a) { return oauth.consumeCode(this, ...a); }
181
+ peekCode(...a) { return oauth.peekCode(this, ...a); }
181
182
  tokenOf(...a) { return oauth.tokenOf(this, ...a); }
182
183
  authed(...a) { return oauth.authed(this, ...a); }
183
184
  redirectAllowed(...a) { return oauth.redirectAllowed(this, ...a); }
@@ -46,38 +46,38 @@ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c =>
46
46
  const parseRedirects = (v) => (Array.isArray(v) ? v : String(v || '').split(/\s+/))
47
47
  .map(s => s.trim()).filter(Boolean);
48
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
+
49
55
  // The sign-in page for an account whose owner signs in at their pod: no
50
56
  // password of ours exists, and none is asked for. The page carries the
51
57
  // client's request in its address, signs the owner in at the pod with the
52
58
  // fediverse-session library, and proves that sign-in back to /oauth/authorize.
53
- function sendPodSigninPage(res, client, webId, mount = '') {
54
- let asking = '';
55
- let allow = 'Allow';
56
- if (client && (client.name || client.redirect)) {
57
- let where = '';
58
- try { where = client.redirect ? new URL(client.redirect).host : ''; } catch { /* oob or blank */ }
59
- const who = client.name ? escapeHtml(client.name) : (where ? escapeHtml(where) : 'A client');
60
- asking = `<p><strong>${who}</strong> is asking to use your account`
61
- + `${where ? `, and you will be sent back to <code>${escapeHtml(where)}</code>` : ''}.</p>`;
62
- allow = `Allow ${who}`;
63
- }
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?`;
64
64
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' });
65
65
  res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
66
- <title>Sign in with your pod</title>
66
+ <title>${question}</title>
67
67
  <style>:root{color-scheme:light dark;--heading:#1a4f8a;--btn:#3d6b35;--line:#9a9a9a}
68
68
  body{font:20px Arial,Helvetica,sans-serif;max-width:28rem;margin:12vh auto;padding:0 1rem}
69
69
  h1{color:var(--heading);font-size:1.5rem}
70
- code{word-break:break-all;background:rgba(120,120,120,.12);padding:.05em .35em;border-radius:4px}
70
+ h1,p{overflow-wrap:anywhere}
71
71
  button{font:inherit;padding:.6rem 1.1rem;background:var(--btn);color:#fff;border:none;border-radius:9px;cursor:pointer}
72
72
  button[hidden]{display:none}
73
73
  #cancel{background:transparent;color:inherit;border:2px solid currentColor;margin-left:.6rem}
74
74
  #signin-status{min-height:1.5em;padding:.6rem .8rem;border:1px solid var(--line);border-radius:8px;margin-top:1rem}
75
+ #signin-status:empty{min-height:0;padding:0;border:0;margin:0}
75
76
  @media (prefers-color-scheme:dark){:root{--heading:#7fb3e8;--btn:#4a7a40}}</style></head><body>
76
77
  <main>
77
- <h1>Sign in with your pod</h1>
78
- ${asking}
79
- <p>This account belongs to <a id="webid" href="${escapeHtml(webId)}">${escapeHtml(webId)}</a>. Sign in at that pod to allow it.</p>
80
- <p><button type="button" id="signin" hidden>${allow}</button><button type="button" id="cancel" hidden>Cancel</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>
81
81
  <p id="signin-status" role="status" aria-live="polite"></p>
82
82
  </main>
83
83
  <script type="module" src="${escapeHtml(mount)}/oauth/session/signin.mjs"></script>
@@ -396,6 +396,13 @@ export function mintCode(api, { clientId, redirectUri, scope, challenge = null,
396
396
  return code;
397
397
  }
398
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
+
399
406
  export function consumeCode(api, code) {
400
407
  const now = Date.now();
401
408
  const all = api.store.read('oauth-codes.json', []);
@@ -424,7 +431,11 @@ export function redirectAllowed(api, redirect) {
424
431
  try {
425
432
  const u = new URL(redirect);
426
433
  if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
427
- 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}/`);
428
439
  } catch { return false; }
429
440
  }
430
441
 
@@ -482,7 +493,7 @@ export async function handle(api, ctx) {
482
493
  // The way back from the pod's login: no client named, only the pod's
483
494
  // code. The page finishes the sign-in and returns to the client's request.
484
495
  if (req.method === 'GET' && !params.get('client_id') && params.get('code') && params.get('state') && ownerWebId) {
485
- return sendPodSigninPage(res, null, ownerWebId, api.mount || '');
496
+ return sendPodSigninPage(res, null, ownerWebId, api.mount || '', ownAddress(api));
486
497
  }
487
498
  const app = api.findApp(params.get('client_id') || '');
488
499
  // A client that published its own metadata document needs no
@@ -526,9 +537,15 @@ export async function handle(api, ctx) {
526
537
  // about a web page. Anything pointing elsewhere has to be approved by
527
538
  // somebody who knows the password.
528
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
+ }
529
546
  if (external && !proved && !api.store.getConfig()?.uiPassword && !api.redirectAllowed(redirect)) {
530
547
  // With a pod to sign in at, the owner approves the client there.
531
- 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));
532
549
  api.log(`authorize refused: no UI password, and "${redirect}" is not an address of this agent`);
533
550
  return send(403, {
534
551
  error: 'this client asks to be sent somewhere other than this agent, and no password is '
@@ -574,7 +591,7 @@ export async function handle(api, ctx) {
574
591
  return sendLoginForm(res, params, '', client);
575
592
  } else if (!proved && api.allowed && !api.allowed.isLocalRequest(req) && !api.throughDoor?.(req)) {
576
593
  // With a pod to sign in at, the owner does that instead of a password.
577
- 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));
578
595
  // No password set, and this request did not come from this machine.
579
596
  //
580
597
  // The instant-authorize path is honest theatre on loopback: whoever can
@@ -619,7 +636,7 @@ export async function handle(api, ctx) {
619
636
  // challenge is the whole of its proof. The code carries the document's
620
637
  // URL as the client it was bound to.
621
638
  if (!app && body.code_verifier && /^https:\/\//iu.test(String(body.client_id || ''))) {
622
- const rec = api.consumeCode(body.code || '');
639
+ const rec = api.peekCode(body.code || '');
623
640
  if (!rec || rec.clientId !== body.client_id
624
641
  || (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
625
642
  api.log('token refused: code is not a live authorization for that client document');
@@ -629,6 +646,7 @@ export async function handle(api, ctx) {
629
646
  api.log('token refused: the verifier does not answer the challenge this code was made with');
630
647
  return send(400, { error: 'invalid_grant' });
631
648
  }
649
+ if (!api.consumeCode(body.code || '')) return send(400, { error: 'invalid_grant' });
632
650
  return send(200, { access_token: api.mintToken(rec.scope || 'read'), token_type: 'Bearer',
633
651
  scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
634
652
  ...(api.urls?.actor ? { activitypub_actor_id: api.urls.actor } : {}) });
@@ -638,7 +656,7 @@ export async function handle(api, ctx) {
638
656
  // challenge it presented at authorize (RFC 7636). Sending a verifier is
639
657
  // what says which of the two flows this is.
640
658
  if (app && body.code_verifier) {
641
- const rec = api.consumeCode(body.code || '');
659
+ const rec = api.peekCode(body.code || '');
642
660
  if (!rec || rec.clientId !== app.clientId || (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
643
661
  api.log('token refused: code is not a live authorization for this client');
644
662
  return send(400, { error: 'invalid_grant' });
@@ -649,6 +667,7 @@ export async function handle(api, ctx) {
649
667
  api.log('token refused: the verifier does not answer the challenge this code was made with');
650
668
  return send(400, { error: 'invalid_grant' });
651
669
  }
670
+ if (!api.consumeCode(body.code || '')) return send(400, { error: 'invalid_grant' });
652
671
  return send(200, { access_token: api.mintToken(rec.scope || 'read'), token_type: 'Bearer',
653
672
  scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
654
673
  ...(api.urls?.actor ? { activitypub_actor_id: api.urls.actor } : {}) });
@@ -658,7 +677,7 @@ export async function handle(api, ctx) {
658
677
  const known = Buffer.from(app.clientSecret);
659
678
  const okSecret = given.length === known.length && crypto.timingSafeEqual(given, known);
660
679
  if (!okSecret) { api.log('token refused: client secret mismatch'); return send(401, { error: 'invalid_client' }); }
661
- const rec = api.consumeCode(body.code || '');
680
+ const rec = api.peekCode(body.code || '');
662
681
  if (!rec || rec.clientId !== app.clientId || (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
663
682
  api.log('token refused: code is not a live authorization for this client');
664
683
  return send(400, { error: 'invalid_grant' });
@@ -669,6 +688,7 @@ export async function handle(api, ctx) {
669
688
  api.log('token refused: this code was made with a challenge and the verifier does not answer it');
670
689
  return send(400, { error: 'invalid_grant' });
671
690
  }
691
+ if (!api.consumeCode(body.code || '')) return send(400, { error: 'invalid_grant' });
672
692
  return send(200, { access_token: api.mintToken(rec.scope || 'read'), token_type: 'Bearer',
673
693
  scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
674
694
  // Which actor the token acts for. A Mastodon client ignores it; an
@@ -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.
@@ -435,6 +435,15 @@ export class PodStore {
435
435
  // arrival order — the Mastodon-API facade serves timelines from this.
436
436
  // [{ noteId, actor, content, published, inReplyTo, kind: 'timeline'|'post'|'tag' }]
437
437
  getStatuses() { return this.read('statuses.json', []); }
438
+ // When each open poll closes, read in place: asked every 30 seconds, where
439
+ // a copy of the whole timeline each time was the whole cost of asking.
440
+ openPollEnds() {
441
+ const out = [];
442
+ for (const s of this.cache.get('statuses.json') || []) {
443
+ if (s?.kind === 'post' && s.poll && !s.poll.closed && s.poll.expiresAt) out.push(Date.parse(s.poll.expiresAt));
444
+ }
445
+ return out;
446
+ }
438
447
  // A count must not pay for a clone: nodeinfo and the instance document are
439
448
  // public and answer strangers, so they read the cache directly.
440
449
  countStatuses(kind = null) {
@@ -482,6 +491,11 @@ export class PodStore {
482
491
  const all = this.getStatuses();
483
492
  const i = all.findIndex(x => x.noteId === noteId);
484
493
  if (i < 0) return null;
494
+ // An edit is held to the size a new post is: a remote Update could
495
+ // otherwise put a post of megabytes into the document rewritten on every change.
496
+ if (typeof patch?.content === 'string' && patch.content.length > MAX_CONTENT) {
497
+ patch = { ...patch, content: clamp(patch.content, MAX_CONTENT), truncated: true };
498
+ }
485
499
  all[i] = { ...all[i], ...patch };
486
500
  this.write('statuses.json', all);
487
501
  return all[i];
@@ -304,6 +304,7 @@ export async function startEmbeddedAgent({
304
304
  autoAcceptFollows = true,
305
305
  gateToken = null,
306
306
  uiPath = '/fp/',
307
+ watch = null,
307
308
  }) {
308
309
  const base = podBase.endsWith('/') ? podBase : podBase + '/';
309
310
  const handle = handleFor(base);
@@ -390,6 +391,7 @@ export async function startEmbeddedAgent({
390
391
  gate: makeGate(gateToken, {
391
392
  secureCookie: authorities.secure,
392
393
  cookiePath: mount ? mount + uiPath : '/',
394
+ failClosed: true,
393
395
  }),
394
396
  allowed: authorities,
395
397
  embedded: true,
@@ -402,9 +404,12 @@ export async function startEmbeddedAgent({
402
404
  // A delivery landing in the inbox — through the door, or from any other
403
405
  // writer — wakes the drain at once. Only additions, and only in the inbox:
404
406
  // the drain's own DELETEs must not call it back.
407
+ // `watch(inbox, handler)`, from a server running many identities, is one
408
+ // listener for all of them; without it each listens to the store itself.
405
409
  let coalesce = null;
406
410
  let onChanged = null;
407
- if (typeof resourceStore?.on === 'function') {
411
+ let unwatch = null;
412
+ if (typeof watch === 'function' || typeof resourceStore?.on === 'function') {
408
413
  const inbox = urls.inbox;
409
414
  onChanged = (identifier, activity) => {
410
415
  const kind = String(activity?.value ?? activity ?? '');
@@ -417,16 +422,19 @@ export async function startEmbeddedAgent({
417
422
  }, DRAIN_COALESCE_MS);
418
423
  coalesce.unref?.();
419
424
  };
420
- resourceStore.on('changed', onChanged);
425
+ if (typeof watch === 'function') unwatch = watch(inbox, onChanged);
426
+ else resourceStore.on('changed', onChanged);
421
427
  }
422
428
 
423
429
  const stop = async () => {
424
- if (onChanged) resourceStore.off('changed', onChanged);
430
+ if (unwatch) unwatch();
431
+ else if (onChanged) resourceStore.off('changed', onChanged);
425
432
  surface.streaming?.stop?.();
426
433
  clearTimeout(coalesce);
427
434
  agent.intake?.stop();
428
435
  agent.tagfeed?.stop();
429
436
  agent.bskyfeed?.stop();
437
+ agent.acctfeed?.stop();
430
438
  agent.deliverer?.stop();
431
439
  agent.importer?.stop();
432
440
  clearInterval(agent.schedTimer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "1.49.0",
3
+ "version": "1.49.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",
@@ -55,7 +55,7 @@
55
55
  "install-service": "node bin/fedipod.mjs install-service",
56
56
  "uninstall-service": "node bin/fedipod.mjs uninstall-service",
57
57
  "prepublishOnly": "npm test",
58
- "test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/hold-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs claude/smoke-tests/oauth-signin.test.mjs claude/smoke-tests/app-registration.test.mjs claude/smoke-tests/push-signout.test.mjs claude/smoke-tests/token-fetches.test.mjs && node scripts/check-sizes.mjs && node scripts/test-dependents.mjs && node claude/validation/browser-agent/all.mjs",
58
+ "test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/hold-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs claude/smoke-tests/oauth-signin.test.mjs claude/smoke-tests/app-registration.test.mjs claude/smoke-tests/push-signout.test.mjs claude/smoke-tests/token-fetches.test.mjs claude/smoke-tests/signin-hardening.test.mjs claude/smoke-tests/load-fixes.test.mjs && node scripts/check-sizes.mjs && node scripts/test-dependents.mjs && node claude/validation/browser-agent/all.mjs",
59
59
  "verify-ui": "node scripts/verify-ui.mjs",
60
60
  "build:sw": "node scripts/build-app.mjs --entry web/app/sw-src.mjs --out web/app/dist/sw.js",
61
61
  "build:boot": "node scripts/build-app.mjs --entry web/app/boot.mjs --out web/app/dist/boot.js",
package/vendor/gate.cjs CHANGED
@@ -88,11 +88,18 @@ function cookieValue(header, name) {
88
88
  // AP_ALLOWED_HOSTS has nothing but this token, so the gate has to be total.
89
89
  // `token` may be a function, resolved per request: an identity's secret can
90
90
  // rotate while the server runs, and the very next request sees the new one.
91
- function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCookie = false, cookiePath = '/' } = {}) {
91
+ // `failClosed`: with no token, refuse rather than open — for a gate whose
92
+ // token is only ever missing by accident (an account on a pod server).
93
+ function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCookie = false, cookiePath = '/', failClosed = false } = {}) {
92
94
  const tokenNow = () => (typeof token === 'function' ? token() : token);
93
95
  // gate(req, res) → true when the gate handled the response (caller stops).
94
96
  function gate(req, res) {
95
97
  const t = tokenNow();
98
+ if (!t && failClosed) {
99
+ res.writeHead(401, { 'content-type': 'text/plain' });
100
+ res.end('fedipod: this account has no key to open its pages with\n');
101
+ return true;
102
+ }
96
103
  if (!t) return false;
97
104
  if (tokenOk(req.headers[HEADER], t)) return false;
98
105
  if (tokenOk(cookieValue(req.headers.cookie, COOKIE), t)) return false;
@@ -129,7 +136,8 @@ function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCoo
129
136
  // Websocket upgrades have no res to answer with — just allowed or not.
130
137
  gate.upgradeOk = (req) => {
131
138
  const t = tokenNow();
132
- return !t || tokenOk(req.headers[HEADER], t) || tokenOk(cookieValue(req.headers.cookie, COOKIE), t);
139
+ if (!t) return !failClosed;
140
+ return tokenOk(req.headers[HEADER], t) || tokenOk(cookieValue(req.headers.cookie, COOKIE), t);
133
141
  };
134
142
 
135
143
  return gate;