fedipod 1.30.0 → 1.36.6

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.
Files changed (43) hide show
  1. package/README.md +1 -1
  2. package/gateway.md +37 -0
  3. package/gui.md +2 -2
  4. package/lib/connections/bskyfeed.mjs +6 -0
  5. package/lib/core/deliver.mjs +52 -22
  6. package/lib/core/intake/index.mjs +17 -4
  7. package/lib/core/lease.mjs +7 -7
  8. package/lib/core/store.mjs +25 -5
  9. package/lib/gateway/caches.mjs +36 -0
  10. package/lib/gateway/front-core.mjs +59 -66
  11. package/lib/gateway/gateway-core.mjs +20 -8
  12. package/lib/gateway/headers.mjs +49 -0
  13. package/lib/gateway/notices.mjs +84 -0
  14. package/lib/gateway/quiet.mjs +189 -0
  15. package/lib/pod/containers.mjs +5 -1
  16. package/lib/pod/inbox.mjs +22 -3
  17. package/lib/pod/transport.mjs +36 -3
  18. package/lib/session/README.md +14 -0
  19. package/lib/session/fedi-account.mjs +62 -0
  20. package/lib/session/package.json +10 -2
  21. package/package.json +1 -1
  22. package/run-agent.mjs +16 -0
  23. package/scripts/stage-site.mjs +6 -3
  24. package/web/admin/bar.css +44 -10
  25. package/web/admin/client/index.html +29 -2
  26. package/web/admin/gateway.js +19 -0
  27. package/web/admin/index.html +46 -3
  28. package/web/admin/notices-bar.js +90 -0
  29. package/web/admin/record.js +1 -0
  30. package/web/admin/setup/index.html +29 -2
  31. package/web/admin/upkeep.js +5 -1
  32. package/web/app/admin-facade.mjs +24 -0
  33. package/web/app/agent.mjs +59 -6
  34. package/web/app/boot.mjs +2 -0
  35. package/web/app/deliver-relay.mjs +47 -7
  36. package/web/app/dist/boot.js +39 -4
  37. package/web/app/dist/boot.js.map +2 -2
  38. package/web/app/dist/sw.js +273 -38
  39. package/web/app/dist/sw.js.map +3 -3
  40. package/web/app/update.js +4 -0
  41. package/web/front/admin.html +3 -1
  42. package/web/front/notices.html +69 -0
  43. package/web/front/notices.js +107 -0
package/README.md CHANGED
@@ -72,7 +72,7 @@ client-to-server, dokieli for one, can post as you. It sends to the outbox
72
72
  address in your actor document, which your WebID profile also names, signed in
73
73
  at your pod. The post goes out the next time you open fedipod.net.
74
74
 
75
- **The manage page.** `manage account` in the bar opens it: your profile,
75
+ **The manage page.** `manage`, in the bar's account group, opens it: your profile,
76
76
  aliases, the gateway, key rotation, recovering posts, parking, moving to
77
77
  another server, retiring, and clearing a backlog. It is the same interface
78
78
  the DeviceAgent has, described in [the admin interface](gui.md).
package/gateway.md CHANGED
@@ -153,6 +153,43 @@ on the setup page). Setup reads the account on the pod, keeps its state and
153
153
  key there, attaches at the new gateway, and completes the move when the
154
154
  agent first acts. See [the DeviceAgent](device-agent.md).
155
155
 
156
+ ## Accounts that go quiet
157
+
158
+ A gateway holds no mail. Every delivery it accepts is written into your pod
159
+ inbox, and your agent reads it from there. A BrowserAgent reads only while
160
+ its page is open, so an account nobody opens grows on its pod without limit
161
+ and comes back to a drain of everything at once. fedipod.net keeps two facts
162
+ about each browser account — when its owner last signed in or posted, and
163
+ how much content has arrived since — and acts on them.
164
+
165
+ **Paused.** After about 5,000 posts, replies, likes, boosts and edits since
166
+ you were last here, or when you say so on the manage page, the door accepts
167
+ content and discards it. Follows, unfollows, account moves, deletions and
168
+ blocks still reach your pod. Signing in ends an automatic pause by itself; a
169
+ pause you set lasts until you lift it.
170
+
171
+ **Closed.** After six months without a sign-in, or when you say so on the
172
+ manage page, the address is closed for good: its handle, its actor and its
173
+ door answer 410 Gone, other servers drop the account the next time they look,
174
+ and nobody can take the name. Nothing on your pod is touched. An address that
175
+ had already moved to another gateway keeps answering as moved.
176
+
177
+ Only accounts opened from a browser are counted. A DeviceAgent behind the
178
+ gateway drains its own inbox as it runs, and is never paused or closed by
179
+ time. Accounts from before this was built are counted from their next
180
+ sign-in. A gateway operator sets the cap and the window with
181
+ `FEDIPOD_PAUSE_ITEMS` and `FEDIPOD_CLOSE_DAYS`.
182
+
183
+ ## Notices
184
+
185
+ Whoever runs the gateway can write notices to everyone with an account
186
+ there. A bell at the right end of the bar, on the record page and in the
187
+ client, shows how many this browser has not opened yet; the list gives the
188
+ titles and each one opens on its own. The operator writes, changes and
189
+ removes them at `/notices`, signed in as the admin the way the roster is.
190
+ A notice is a title and plain text: a blank line starts a paragraph, and a
191
+ web address becomes a link.
192
+
156
193
  ## What the gateway can see
157
194
 
158
195
  It reads only public data to decide what concerns you: your published
package/gui.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # The Admin interface
2
2
 
3
- In the browser version at fedipod.net, `manage account` in the bar opens this
3
+ In the browser version at fedipod.net, `manage`, in the bar's account group, opens this
4
4
  same page for your account. The rest of this page describes it as the
5
5
  DeviceAgent serves it; the controls are the same, minus the manual inbox
6
6
  drain and the local log, which a browser does not have.
7
7
 
8
- Open `https://localhost:8030/` while any agent is running — it forwards you to the agent — then choose `manage account` and select the actor you want from the local actors dropdown.
8
+ Open `https://localhost:8030/` while any agent is running — it forwards you to the agent — then choose `manage` in the bar's account group and select the actor you want from the local actors dropdown.
9
9
  Picking an actor marked "(stopped)" starts its agent, then opens its page.
10
10
 
11
11
  The software row names the version the agent is running. When the copy on the machine is further ahead — after an update, or after pulling a checkout — it says so and asks for a restart, because an agent goes on serving the code it started with until it is restarted.
@@ -166,6 +166,10 @@ export class BskyFeed {
166
166
  this.lastSweep = new Date().toISOString();
167
167
  const self = this.atproto.read()?.did;
168
168
  let added = 0;
169
+ // One commit boundary for the sweep, as acctfeed and tagfeed: every item
170
+ // awaits Bluesky before the next, so the debounce never coalesced them and
171
+ // the timeline index went to the pod once per notification.
172
+ this.store.hold?.();
169
173
  try {
170
174
  // Our own CROSS-POSTS must not echo back into the feed — but a post or
171
175
  // reply written natively on Bluesky is ours to see here too. The mirrors
@@ -224,6 +228,8 @@ export class BskyFeed {
224
228
  } catch (e) {
225
229
  this._backOff(e.status || 0, null);
226
230
  return;
231
+ } finally {
232
+ this.store.release?.();
227
233
  }
228
234
  const all = this.store.getStatuses();
229
235
  const mirrored = all.filter(s => s.kind === 'bsky');
@@ -73,6 +73,10 @@ export class Deliverer {
73
73
  this.edPrivate = edPrivate;
74
74
  this.proofKeyId = proofKeyId;
75
75
  this.log = log;
76
+ // How many fresh deliveries one attempt may carry. The Node deliverer
77
+ // opens a socket per recipient; the browser's relay deliverer sends a
78
+ // list, and sets this to what the relay accepts.
79
+ this.batchSize = 1;
76
80
  if (!passive) this.startQueue();
77
81
  }
78
82
 
@@ -171,33 +175,51 @@ export class Deliverer {
171
175
  }
172
176
  }
173
177
 
178
+ // One attempt per target, answered in order: `{ ok: true }` or `{ error }`
179
+ // with the error deliverNow would have thrown. Here one at a time; the
180
+ // relay deliverer sends the whole list in one call.
181
+ async deliverManyNow(targets) {
182
+ const out = [];
183
+ for (const t of targets) {
184
+ try { await this.deliverNow(t.inbox, t.activity); out.push({ ok: true }); }
185
+ catch (error) { out.push({ error }); }
186
+ }
187
+ return out;
188
+ }
189
+
174
190
  async deliver(inbox, activity) {
175
191
  // Proved before anything else, so the copy that goes on the queue is the
176
192
  // copy that was signed — a retry days later must not post a bare activity.
177
193
  const signed = await this.proofed(activity);
178
- // A host we already know is refusing: queue without asking again. This is
179
- // the path a FRESH activity takes, so without it a fan-out to a struggling
180
- // server opened one socket per follower before any of this applied.
194
+ if (this._queueIfCooling(inbox, signed)) return;
195
+ const [result] = await this.deliverManyNow([{ inbox, activity: signed }]);
196
+ await this._settle(inbox, signed, result);
197
+ }
198
+
199
+ // A host we already know is refusing: queue without asking again. This is
200
+ // the path a FRESH activity takes, so without it a fan-out to a struggling
201
+ // server opened one socket per follower before any of this applied.
202
+ _queueIfCooling(inbox, signed) {
181
203
  const host = hostOf(inbox);
182
204
  const until = this._cooling?.get(host);
183
- if (until && until > Date.now()) {
184
- this.log(`${host} is cooling — queueing ${signed.type} rather than asking again`);
185
- this._enqueue({ inbox, activity: signed, attempts: 1, nextAt: until });
186
- return;
187
- }
188
- try {
189
- await this.deliverNow(inbox, signed);
190
- this.log(`delivered ${signed.type} → ${inbox}`);
191
- } catch (e) {
192
- if (unsalvageable(e)) { await this._unsalvageable(inbox, e); return; }
193
- this.log(`delivery failed (${e.message}) — queued`);
194
- const wait = e.retryAfterMs || 60_000;
195
- if (aboutTheHost(e)) {
196
- this._cooling ||= new Map();
197
- this._cooling.set(host, Date.now() + wait);
198
- }
199
- this._enqueue({ inbox, activity: signed, attempts: 1, nextAt: Date.now() + wait });
205
+ if (!until || until <= Date.now()) return false;
206
+ this.log(`${host} is cooling — queueing ${signed.type} rather than asking again`);
207
+ this._enqueue({ inbox, activity: signed, attempts: 1, nextAt: until });
208
+ return true;
209
+ }
210
+
211
+ // What one attempt's outcome means for the queue.
212
+ async _settle(inbox, signed, result) {
213
+ if (result.ok) { this.log(`delivered ${signed.type} → ${inbox}`); return; }
214
+ const e = result.error;
215
+ if (unsalvageable(e)) { await this._unsalvageable(inbox, e); return; }
216
+ this.log(`delivery failed (${e.message}) — queued`);
217
+ const wait = e.retryAfterMs || 60_000;
218
+ if (aboutTheHost(e)) {
219
+ this._cooling ||= new Map();
220
+ this._cooling.set(hostOf(inbox), Date.now() + wait);
200
221
  }
222
+ this._enqueue({ inbox, activity: signed, attempts: 1, nextAt: Date.now() + wait });
201
223
  }
202
224
 
203
225
  // Not retried, and on a 410 the followers who received there are dropped,
@@ -231,8 +253,16 @@ export class Deliverer {
231
253
  // Proved once for the whole fan-out: every recipient gets the same bytes,
232
254
  // and one signature is computed rather than one per follower.
233
255
  const signed = await this.proofed(activity);
234
- // Shared inboxes deduplicate fan-out to the same server.
235
- for (const inbox of [...new Set(inboxes)]) await this.deliver(inbox, signed);
256
+ // Shared inboxes deduplicate fan-out to the same server. Sent in batches
257
+ // of batchSize, and a host found cooling by one batch is not asked again
258
+ // by the next.
259
+ const targets = [...new Set(inboxes)].map((inbox) => ({ inbox, activity: signed }));
260
+ for (let i = 0; i < targets.length; i += this.batchSize) {
261
+ const chunk = targets.slice(i, i + this.batchSize).filter((t) => !this._queueIfCooling(t.inbox, signed));
262
+ if (!chunk.length) continue;
263
+ const results = await this.deliverManyNow(chunk);
264
+ for (let k = 0; k < chunk.length; k++) await this._settle(chunk[k].inbox, signed, results[k]);
265
+ }
236
266
  }
237
267
 
238
268
  // Serialized, for the same reason Intake.drain is: the tick is 60s and a
@@ -51,6 +51,7 @@ const CHAIN_GAP_MS = 5_000; // pause between chained backlog sweeps
51
51
  // enough that a crash re-does little, large enough that a flood of fast
52
52
  // rejections does not become a pod write per item.
53
53
  const DELETE_BATCH = 10;
54
+ const ORPHAN_RECEIPTS_PER_SWEEP = 20; // strays from before receipts were deleted with their items
54
55
  // Attempt counts live in pod state, not in memory: a restart used to hand every
55
56
  // poison item five fresh tries, and under a crash loop that is unbounded.
56
57
  const ATTEMPTS_DOC = 'intake-attempts.json';
@@ -359,6 +360,7 @@ export class Intake {
359
360
  // deadletter.json where one would do, and a flood is exactly when the pod
360
361
  // should be asked for less rather than more.
361
362
  const pending = [];
363
+ const withReceipt = new Set(); // items the listing showed a receipt beside
362
364
  const flush = async () => {
363
365
  if (!pending.length) return true;
364
366
  // Written down before any of them leaves the mailbox. A failure here
@@ -379,13 +381,17 @@ export class Intake {
379
381
  }
380
382
  this._clearAttempt(url);
381
383
  handled++;
384
+ // Its receipt goes with it. Left beside a deleted item, a receipt
385
+ // was one stray document per delivery, forever.
386
+ if (withReceipt.has(url)) await podInbox.dropReceiptBeside(this.remote, url);
382
387
  await new Promise(r => setTimeout(r, DELETE_GAP_MS));
383
388
  }
384
389
  return true;
385
390
  };
386
391
 
387
- for (const { url, size } of items) {
392
+ for (const { url, size, receipt: hasReceipt } of items) {
388
393
  if (url.endsWith('.keep')) continue;
394
+ if (hasReceipt) withReceipt.add(url);
389
395
  // The listing already carries every child's size, so this costs nothing
390
396
  // to ask. An activity is a few kB; anything of this order is not one, and
391
397
  // reading it with an unbounded res.text() buffers whatever a stranger
@@ -430,9 +436,11 @@ export class Intake {
430
436
  }
431
437
  // A gateway that verified this delivery left a receipt beside it. Read
432
438
  // it only when a gateway is configured (no config → no fetch, so an
433
- // install with no gateway pays nothing); a missing or HMAC-invalid
434
- // receipt reads as null, which is exactly today's unverified behavior.
435
- const receipt = activity ? await this._readReceipt(url) : null;
439
+ // install with no gateway pays nothing) and the listing showed one
440
+ // beside this item (`false` is a definite no; a listing that does not
441
+ // say leaves it to the read); a missing or HMAC-invalid receipt reads
442
+ // as null, which is exactly today's unverified behavior.
443
+ const receipt = activity && hasReceipt !== false ? await this._readReceipt(url) : null;
436
444
  if (activity && this.gatewaySecret()) this._bumpGatewayStat(!!receipt?.verified);
437
445
  // The owner's own post, taken at the outbox door: not mail to read
438
446
  // but a write to make. Never archived or forwarded as if received.
@@ -464,6 +472,11 @@ export class Intake {
464
472
  if (pending.length >= DELETE_BATCH && !await flush()) return;
465
473
  }
466
474
  if (!await this._finishSweep(flush)) return;
475
+ // Receipts earlier drains left behind, a few per sweep until none remain.
476
+ for (const stray of podInbox.orphanReceipts(this.remote, this.urls).slice(0, ORPHAN_RECEIPTS_PER_SWEEP)) {
477
+ if (!await podInbox.dropStrayReceipt(this.remote, stray)) break;
478
+ await new Promise(r => setTimeout(r, DELETE_GAP_MS));
479
+ }
467
480
  // Made progress and there is more waiting: go straight round rather than
468
481
  // sleeping. Gated on progress so a sweep that achieved nothing — a
469
482
  // cooldown, an unwritable store, poison at the head — cannot spin.
@@ -9,13 +9,13 @@
9
9
  import crypto from 'node:crypto';
10
10
 
11
11
  // Renewal is the one thing that writes even when nothing is happening: at 30s
12
- // it was 120 PUTs an hour per agent, each taking a write lock on the pod. 90s
13
- // against a 5-minute TTL leaves three missed renewals of headroom and cuts that
14
- // load by two thirds. The cost is automatic promotion after a crash waiting up
15
- // to the TTL — a user acting on a second device does not wait, since takeover()
16
- // claims the lease outright.
17
- const TTL_MS = 300_000;
18
- const RENEW_MS = 90_000;
12
+ // it was 120 PUTs an hour per agent, each taking a write lock on the pod; at
13
+ // 90s, 40. Five minutes against a 15-minute TTL leaves two missed renewals of
14
+ // headroom and makes it 12. The cost is automatic promotion after a crash
15
+ // waiting up to the TTL — a user acting on a second device does not wait,
16
+ // since takeover() claims the lease outright.
17
+ const TTL_MS = 900_000;
18
+ const RENEW_MS = 300_000;
19
19
  const JITTER = () => 0.85 + Math.random() * 0.3;
20
20
  // Distinct from null: null is "nobody holds it", this is "we could not ask".
21
21
  const UNREADABLE = Symbol('lease-unreadable');
@@ -72,6 +72,11 @@ export function dropFollower(contacts, actor, why) {
72
72
  return contacts;
73
73
  }
74
74
 
75
+ // What a state document looks like on the pod; write() and _put() agree on it.
76
+ const serialise = (obj) => JSON.stringify(obj, null, 2) + '\n';
77
+ // The same record apart from when it was fetched.
78
+ const sameButWhen = (a, b) => JSON.stringify({ ...a, fetchedAt: null }) === JSON.stringify({ ...b, fetchedAt: null });
79
+
75
80
  export class PodStore {
76
81
  constructor({ storage = null, log = console.log } = {}) {
77
82
  this.lastSkipped = []; // what the last load could not read, as `name (HTTP n)`
@@ -79,6 +84,7 @@ export class PodStore {
79
84
  this.log = log;
80
85
  this.cache = new Map(); // name → parsed value
81
86
  this.etags = new Map(); // name (or '') → last ETag, for revalidation
87
+ this.lastText = new Map(); // name → the bytes last loaded from or sent to the pod
82
88
  this.timers = new Map(); // name → debounce timer
83
89
  this.dirty = new Set(); // written while held; flushed by commit/release
84
90
  this._held = 0;
@@ -91,7 +97,7 @@ export class PodStore {
91
97
  attach(storage) {
92
98
  // A different tree is different state: carrying the old cache and its
93
99
  // ETags across would serve one container's documents as another's.
94
- if (this.storage && this.storage.base !== storage.base) { this.cache.clear(); this.etags.clear(); }
100
+ if (this.storage && this.storage.base !== storage.base) { this.cache.clear(); this.etags.clear(); this.lastText.clear(); }
95
101
  this.storage = storage;
96
102
  }
97
103
 
@@ -144,7 +150,7 @@ export class PodStore {
144
150
  }
145
151
  fetched++;
146
152
  this.etags.set(name, r.etag);
147
- try { this.cache.set(name, JSON.parse(r.body)); }
153
+ try { this.cache.set(name, JSON.parse(r.body)); this.lastText.set(name, r.body); }
148
154
  catch (e) { this.log(`state load ${name}: unparsable (${e.message})`); }
149
155
  }
150
156
  // Kept for /status: a document skipped here is a timeline or a contact
@@ -167,6 +173,15 @@ export class PodStore {
167
173
  write(name, obj) {
168
174
  this.cache.set(name, structuredClone(obj));
169
175
  if (!this.storage) return;
176
+ // The same bytes the pod already holds are not sent again. A like, a
177
+ // bookmark, a sweep that found nothing new each used to upload the whole
178
+ // timeline index and the whole people cache, unchanged — the largest
179
+ // write on the pod, on every small action. A write that had been armed
180
+ // and is now undone is unarmed with it.
181
+ if (this.lastText.get(name) === serialise(obj)) {
182
+ clearTimeout(this.timers.get(name)); this.timers.delete(name); this.dirty.delete(name);
183
+ return;
184
+ }
170
185
  // Held: record it and leave the writing to the commit boundary the caller
171
186
  // already has. See hold().
172
187
  if (this._held) { this.dirty.add(name); return; }
@@ -205,13 +220,13 @@ export class PodStore {
205
220
  // the returned promise so one failure cannot poison the queue.
206
221
  _put(name) {
207
222
  const done = this.chain.then(async () => {
208
- const body = JSON.stringify(this.cache.get(name), null, 2) + '\n';
223
+ const body = serialise(this.cache.get(name));
209
224
  for (let attempt = 1; attempt <= PUT_RETRIES; attempt++) {
210
225
  // A storage that throws is a bug, not a hiccup — but it must not
211
226
  // escape into the write queue, where it would look like success.
212
227
  const r = await this.storage.write(name, body, 'application/json')
213
228
  .catch(e => ({ ok: false, retry: false, why: e.message }));
214
- if (r.ok) return true;
229
+ if (r.ok) { this.lastText.set(name, body); return true; }
215
230
  // The storage says whether trying again could possibly help: a pod's
216
231
  // 4xx is an answer rather than a hiccup, and a directory's EACCES will
217
232
  // still be an EACCES in two seconds.
@@ -236,6 +251,7 @@ export class PodStore {
236
251
  // migrates to the local machine — leaving the copy behind would defeat it).
237
252
  async remove(name) {
238
253
  this.cache.delete(name);
254
+ this.lastText.delete(name);
239
255
  clearTimeout(this.timers.get(name));
240
256
  this.timers.delete(name);
241
257
  if (!this.storage) return true;
@@ -462,6 +478,7 @@ export class PodStore {
462
478
  getActors() { return this.read('actors.json', {}); }
463
479
  cacheActor(url, doc) {
464
480
  const a = this.getActors();
481
+ const known = a[url];
465
482
  a[url] = {
466
483
  name: clamp(plainText(doc.name || doc.preferredUsername || ''), MAX_NAME),
467
484
  preferredUsername: clamp(plainText(doc.preferredUsername || ''), MAX_NAME),
@@ -474,9 +491,12 @@ export class PodStore {
474
491
  type: doc.type || 'Person',
475
492
  followers: doc.followers || null,
476
493
  following: doc.following || null,
477
- ...(a[url]?.counts ? { counts: a[url].counts } : {}),
494
+ ...(known?.counts ? { counts: known.counts } : {}),
478
495
  fetchedAt: new Date().toISOString(),
479
496
  };
497
+ // Fetched again and found the same: nothing to write. Only the time of
498
+ // asking differs, and that is not worth the whole cache going to the pod.
499
+ if (known && sameButWhen(known, a[url])) { a[url] = known; return; }
480
500
  this.write('actors.json', prune(a, ACTOR_CACHE_MAX, this.getContacts()));
481
501
  }
482
502
 
@@ -0,0 +1,36 @@
1
+ // caches.mjs — what one warm gateway process keeps between requests: the
2
+ // pod-token verifier, and the public keys of the servers that deliver here.
3
+ // Built per request, each costs round trips to somebody else's server for
4
+ // every call; built once, a process pays them once.
5
+
6
+ // The Solid-OIDC verifier remembers the issuers' key sets it has seen. A
7
+ // fresh one per call re-read the caller's WebID document, the issuer's
8
+ // discovery document and its keys on every signed-in call.
9
+ let verifier = null;
10
+ export async function podTokenVerifier() {
11
+ if (!verifier) verifier = (await import('@solid/access-token-verifier')).createSolidTokenVerifier();
12
+ return verifier;
13
+ }
14
+
15
+ // The keys deliveries are signed with, by key id, in the shape Fedify's
16
+ // verifier asks for. A key is held an hour; a key that could not be fetched
17
+ // is held five minutes, so a server that was down is asked again soon.
18
+ // Fedify re-fetches on its own when a held key no longer verifies, so a
19
+ // rotated key costs one failed check, not an hour of refused mail.
20
+ const KEY_TTL_MS = 60 * 60_000;
21
+ const MISS_TTL_MS = 5 * 60_000;
22
+ const KEY_CACHE_MAX = 500;
23
+ const keys = new Map(); // key id → { key, until }
24
+ export const senderKeys = {
25
+ async get(keyId) {
26
+ const hit = keys.get(keyId.href);
27
+ if (!hit) return undefined;
28
+ if (hit.until < Date.now()) { keys.delete(keyId.href); return undefined; }
29
+ return hit.key;
30
+ },
31
+ async set(keyId, key) {
32
+ if (keys.size >= KEY_CACHE_MAX) keys.delete(keys.keys().next().value);
33
+ keys.set(keyId.href, { key, until: Date.now() + (key ? KEY_TTL_MS : MISS_TTL_MS) });
34
+ },
35
+ size: () => keys.size,
36
+ };