fedipod 1.32.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.
@@ -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
+ };
@@ -23,6 +23,8 @@ import * as podPolicy from '../pod/policy.mjs';
23
23
  import { podBaseOfWebId } from '../pod/urls.mjs';
24
24
  import { routeQuietApi, noteOpened, noteReceived, closedState, accountState, closedAnswer } from './quiet.mjs';
25
25
  import { routeNoticesApi } from './notices.mjs';
26
+ import { withSecurityHeaders } from './headers.mjs';
27
+ import { podTokenVerifier } from './caches.mjs';
26
28
 
27
29
  // The one WebFinger document, spelled out here rather than imported from
28
30
  // wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
@@ -61,7 +63,7 @@ async function verifyPodToken(request, pathname, verifier) {
61
63
  // Solid-OIDC binds the token to a key the client proves on every request;
62
64
  // a token shown without the proof is one anyone who saw it could show.
63
65
  if (!dpop) { console.log(`front: pod token without a DPoP proof refused on ${pathname}`); return null; }
64
- const v = verifier || (await import('@solid/access-token-verifier')).createSolidTokenVerifier();
66
+ const v = verifier || await podTokenVerifier();
65
67
  const url = request.url;
66
68
  const { webid } = await v(authz, { header: dpop, method: request.method, url });
67
69
  return webid || null;
@@ -207,7 +209,9 @@ async function relayOne(item, rec, fetchImpl) {
207
209
 
208
210
  const j = (status, obj, ct = 'application/json') =>
209
211
  ({ status, headers: { 'content-type': ct, 'cache-control': 'no-store' }, body: JSON.stringify(obj) });
210
- const notFound = () => ({ status: 404, headers: { 'content-type': 'text/plain' }, body: 'not found\n' });
212
+ // Held at the edge like any other missing document: a bot's scan, a dead
213
+ // handle, a typo in a WebFinger query each cost one call, not one per asker.
214
+ const notFound = () => ({ status: 404, headers: { 'content-type': 'text/plain', ...publicFor(MISSING_EDGE_SECONDS) }, body: 'not found\n' });
211
215
 
212
216
  // A user's public base on the front, a 1:1 mirror of their pod home.
213
217
  // The directory key is the FULL fediverse address — handle@host — never the
@@ -243,7 +247,9 @@ function parseUserPath(pathname) {
243
247
  const BROWSER_MAX_AGE = 60;
244
248
  const publicFor = (seconds, stale = seconds * 4) => ({
245
249
  'cache-control': `public, max-age=${Math.min(seconds, BROWSER_MAX_AGE)}`,
246
- 'netlify-cdn-cache-control': `public, s-maxage=${seconds}, stale-while-revalidate=${stale}`,
250
+ // `durable`: one copy for every region, and one that a deploy does not
251
+ // empty. Without it each region held its own and every deploy started cold.
252
+ 'netlify-cdn-cache-control': `public, durable, s-maxage=${seconds}, stale-while-revalidate=${stale}`,
247
253
  });
248
254
  // How long the edge holds a public document, and a handle's WebFinger
249
255
  // answer. Every server that has heard of an account asks for its actor and
@@ -252,6 +258,15 @@ const publicFor = (seconds, stale = seconds * 4) => ({
252
258
  // can afford.
253
259
  const PUBLIC_EDGE_SECONDS = 600;
254
260
  const WEBFINGER_EDGE_SECONDS = 3600;
261
+ // A closed or moved address stays gone; a picture's address stays where it is.
262
+ const GONE_EDGE_SECONDS = 3600;
263
+ const MEDIA_EDGE_SECONDS = 86400;
264
+ // A public document the pod would not give (missing, or not public) is asked
265
+ // for again and again by every server that shows the account — a pinned-posts
266
+ // collection an old account never wrote, a forum count nobody published. Held
267
+ // briefly, so the edge absorbs the asking; briefly, so a document published a
268
+ // moment later is not "missing" for long.
269
+ const MISSING_EDGE_SECONDS = 120;
255
270
 
256
271
  // Where a pod owner opts their identity in. An operator may name it, because
257
272
  // the path it takes is one their pod server can no longer serve; the dot says
@@ -344,51 +359,6 @@ export async function routeFront(request, ctx) {
344
359
  return { ...out, headers: withSecurityHeaders(out.headers, out.body) };
345
360
  }
346
361
 
347
- // Every response this file makes, hardened in one place rather than in each of
348
- // the dozen shapes below.
349
- //
350
- // `nosniff` matters most: the front serves user-supplied JSON straight from
351
- // somebody's pod (the proxied actor and object documents), and without it a
352
- // browser is free to decide for itself that a document is HTML and run what is
353
- // inside it. The rest is the same posture the app already has — nothing may be
354
- // framed, no base tag may be rewritten, no plugin content.
355
- //
356
- // A content-security-policy goes on the HTML only: it would mean nothing on a
357
- // JSON document, and `frame-ancestors` has to be a header rather than a meta
358
- // tag anyway.
359
- //
360
- // `script-src 'self'` is the one that matters, and it is only possible because
361
- // none of these pages carries inline script any more — each has its own file and
362
- // its own route above. A policy cannot tell an inline block the author wrote
363
- // from one an attacker injected, so as long as any inline script has to run,
364
- // every inline script may.
365
- //
366
- // `connect-src` allows https: because the pages sign in against the user's own
367
- // pod, which is a different origin by definition and not one we can name here.
368
- function withSecurityHeaders(headers = {}, body = null) {
369
- const ct = String(headers['content-type'] || '');
370
- const isHtml = ct.startsWith('text/html');
371
- return {
372
- ...headers,
373
- 'x-content-type-options': 'nosniff',
374
- 'referrer-policy': 'same-origin',
375
- 'x-frame-options': 'SAMEORIGIN',
376
- ...(isHtml && body ? {
377
- 'content-security-policy': [
378
- "default-src 'self'",
379
- "script-src 'self'", // no inline script: see above
380
- "style-src 'self' 'unsafe-inline'",
381
- "img-src 'self' https: data:",
382
- "connect-src 'self' https:", // sign-in goes to the user's own pod
383
- "object-src 'none'", // no plugin content, ever
384
- "base-uri 'none'", // no rewriting where relative URLs resolve
385
- "frame-ancestors 'self'", // nobody else may frame these pages
386
- "form-action 'self'", // a form here submits here
387
- ].join('; '),
388
- } : {}),
389
- };
390
- }
391
-
392
362
  async function route(request, ctx) {
393
363
  const url = new URL(request.url);
394
364
  const { pathname } = url;
@@ -643,8 +613,8 @@ async function route(request, ctx) {
643
613
  }
644
614
 
645
615
  // The owner's say over an account that goes quiet (quiet.mjs).
646
- const quiet = await routeQuietApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod });
647
- if (quiet) return quiet;
616
+ const quiet = await routeQuietApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod, apiPreflight });
617
+ if (quiet) return withApiCors(quiet);
648
618
 
649
619
  // The relay: the front sends requests a browser has already signed. A page
650
620
  // may not set the Date or Host header, and both are inside an HTTP
@@ -808,7 +778,7 @@ async function route(request, ctx) {
808
778
  if (!rec) return notFound();
809
779
  // A closed address is gone, and says so rather than pretending never to
810
780
  // have existed: the name stays taken.
811
- if ((await closedState(ctx, m[1], rec)).closed) return closedAnswer({ 'access-control-allow-origin': '*' });
781
+ if ((await closedState(ctx, m[1], rec)).closed) return closedAnswer({ 'access-control-allow-origin': '*', ...publicFor(GONE_EDGE_SECONDS) });
812
782
  // A fronted identity's documents live on its pod; the pod's own actor id
813
783
  // is the alias, so a client signing in by the fronted address can find
814
784
  // the pod (and its login) without a lookup only the host could answer.
@@ -909,8 +879,8 @@ async function route(request, ctx) {
909
879
  const { status, reason, location } = await handleOwnerPost(request, identFor(rec),
910
880
  { podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), ownerWebId: webid });
911
881
  console.log(`door @${up.handle}: owner post → ${status} (${reason})`);
912
- if (status !== 202) return json(status, { error: reason });
913
- return json(202, { accepted: true, ...(location ? { object: location } : {}),
882
+ if (status !== 201) return json(status, { error: reason });
883
+ return json(201, { accepted: true, ...(location ? { object: location } : {}),
914
884
  note: 'it goes out when your FediPod agent next runs' }, location ? { location } : {});
915
885
  }
916
886
  if (rec.inboxOnly && (request.method === 'GET' || request.method === 'HEAD')) {
@@ -935,14 +905,14 @@ async function route(request, ctx) {
935
905
  if (movedBase && up.rest !== 'ap/actor') {
936
906
  return { status: 301, headers: { ...open, location: movedBase + up.rest, 'cache-control': 'no-store' }, body: '' };
937
907
  }
938
- if (closed) return gone(open, CLOSED);
908
+ if (closed) return gone({ ...open, ...publicFor(GONE_EDGE_SECONDS) }, CLOSED);
939
909
  const podTarget = rec.podHome + up.rest;
940
910
  // Media stays on the pod (lib/pod/urls.mjs keeps `media` off the front), but
941
911
  // the id rewrite below turns media links onto the front like every other
942
912
  // pod url in a document. Answer those by pointing at the pod: bytes are not
943
913
  // a document to cap and relabel, and remotes follow a redirect for a picture.
944
914
  if (up.rest.startsWith('ap/media/')) {
945
- return { status: 302, headers: { location: podTarget, 'cache-control': 'no-store' }, body: '' };
915
+ return { status: 302, headers: { location: podTarget, ...publicFor(MEDIA_EDGE_SECONDS) }, body: '' };
946
916
  }
947
917
  // The pod this read belongs to travels with it: an adapter reading a store
948
918
  // directly (the CSS server component) has no access control of its own and
@@ -958,7 +928,10 @@ async function route(request, ctx) {
958
928
  const got = await podRoot.readPublicDocument(
959
929
  ctx.podGet || ((u) => fetch(u, { headers: { accept } })),
960
930
  podTarget, { podHome: rec.podHome, accept });
961
- if (got.text === null) return { status: got.status, headers: open, body: '' };
931
+ if (got.text === null) {
932
+ const hold = [401, 403, 404, 410].includes(got.status) ? publicFor(MISSING_EDGE_SECONDS) : {};
933
+ return { status: got.status, headers: { ...open, ...hold }, body: '' };
934
+ }
962
935
  let text = got.text;
963
936
  text = swap(text, rec.podHome, base);
964
937
  // The moved stub: the pod's actor now carries the NEW gateway's ids, and a
@@ -15,6 +15,7 @@
15
15
  import crypto from 'node:crypto';
16
16
  import { verifyHttpSignature, makeSafeLoader, makeReceipt, signReceipt } from './httpsig.mjs';
17
17
  import * as inbox from '../pod/inbox.mjs';
18
+ import { senderKeys } from './caches.mjs';
18
19
 
19
20
  const DEFAULT_MAX_BYTES = 512 * 1024; // mirror intake.mjs MAX_ITEM_BYTES
20
21
 
@@ -93,8 +94,10 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
93
94
  return { status: 202, reason: 'does not concern us' };
94
95
  }
95
96
 
97
+ // The sender's key, kept between deliveries: a server pushing a hundred
98
+ // items is asked for its key once (caches.mjs).
96
99
  const v = await verifyHttpSignature(request, {
97
- documentLoader: makeSafeLoader({ fetchImpl }),
100
+ documentLoader: makeSafeLoader({ fetchImpl }), keyCache: senderKeys,
98
101
  });
99
102
  // A present-but-invalid signature is a forgery — dropped here, so it never
100
103
  // reaches the pod (today it would, drain, and die unapplied). An absent or
@@ -156,7 +159,7 @@ export async function handleOwnerPost(request, ident, { podPut, ownerWebId, maxB
156
159
  const okA = await inbox.appendVerifiedDelivery(podPut, ident.inboxUrl, hash, raw);
157
160
  if (!okA) return { status: 502, reason: 'pod inbox write failed' };
158
161
  await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
159
- return { status: 202, reason: 'accepted', location: slug && ident.notesPrefix ? ident.notesPrefix + slug : null };
162
+ return { status: 201, reason: 'accepted', location: slug && ident.notesPrefix ? ident.notesPrefix + slug : null };
160
163
  }
161
164
 
162
165
  export const _internal = { isBlocked, concernsUsAtEdge, httpUrl, sha256hex };
@@ -0,0 +1,49 @@
1
+ // headers.mjs — the hardening every front response carries, in one place.
2
+ // Split out of front-core.mjs, which is at its size gate; nothing here knows
3
+ // about routes.
4
+
5
+ // Every response this file makes, hardened in one place rather than in each of
6
+ // the dozen shapes below.
7
+ //
8
+ // `nosniff` matters most: the front serves user-supplied JSON straight from
9
+ // somebody's pod (the proxied actor and object documents), and without it a
10
+ // browser is free to decide for itself that a document is HTML and run what is
11
+ // inside it. The rest is the same posture the app already has — nothing may be
12
+ // framed, no base tag may be rewritten, no plugin content.
13
+ //
14
+ // A content-security-policy goes on the HTML only: it would mean nothing on a
15
+ // JSON document, and `frame-ancestors` has to be a header rather than a meta
16
+ // tag anyway.
17
+ //
18
+ // `script-src 'self'` is the one that matters, and it is only possible because
19
+ // none of these pages carries inline script any more — each has its own file and
20
+ // its own route above. A policy cannot tell an inline block the author wrote
21
+ // from one an attacker injected, so as long as any inline script has to run,
22
+ // every inline script may.
23
+ //
24
+ // `connect-src` allows https: because the pages sign in against the user's own
25
+ // pod, which is a different origin by definition and not one we can name here.
26
+ export function withSecurityHeaders(headers = {}, body = null) {
27
+ const ct = String(headers['content-type'] || '');
28
+ const isHtml = ct.startsWith('text/html');
29
+ return {
30
+ ...headers,
31
+ 'x-content-type-options': 'nosniff',
32
+ 'referrer-policy': 'same-origin',
33
+ 'x-frame-options': 'SAMEORIGIN',
34
+ ...(isHtml && body ? {
35
+ 'content-security-policy': [
36
+ "default-src 'self'",
37
+ "script-src 'self'", // no inline script: see above
38
+ "style-src 'self' 'unsafe-inline'",
39
+ "img-src 'self' https: data:",
40
+ "connect-src 'self' https:", // sign-in goes to the user's own pod
41
+ "object-src 'none'", // no plugin content, ever
42
+ "base-uri 'none'", // no rewriting where relative URLs resolve
43
+ "frame-ancestors 'self'", // nobody else may frame these pages
44
+ "form-action 'self'", // a form here submits here
45
+ ].join('; '),
46
+ } : {}),
47
+ };
48
+ }
49
+
@@ -101,7 +101,7 @@ export async function accountState(ctx, key, rec) {
101
101
 
102
102
  // A 410 with a reason, the shape every closed or moved id answers with.
103
103
  export const closedAnswer = (headers = {}, why = 'this address is closed') => ({
104
- status: 410, headers: { ...headers, 'content-type': 'application/json', 'cache-control': 'no-store' },
104
+ status: 410, headers: { 'cache-control': 'no-store', ...headers, 'content-type': 'application/json' },
105
105
  body: JSON.stringify({ error: why }) });
106
106
 
107
107
  // The owner of a row, proved the way the relay proves them: a pod token whose
@@ -120,7 +120,12 @@ async function provedOwner(request, pathname, ctx, rec, { j, verifyPodToken, web
120
120
  // Returns a response, or null when the path is not one of these.
121
121
  export async function routeQuietApi(request, pathname, ctx, deps) {
122
122
  const { j } = deps;
123
- if (request.method !== 'POST' || !['/api/open', '/api/pause', '/api/close'].includes(pathname)) return null;
123
+ if (!['/api/open', '/api/pause', '/api/close'].includes(pathname)) return null;
124
+ // A page at another origin asks first whether it may call, and is answered
125
+ // as the relay answers it. Unanswered, the browser never sends the call and
126
+ // some pages asked again every minute.
127
+ if (request.method === 'OPTIONS') return deps.apiPreflight();
128
+ if (request.method !== 'POST') return null;
124
129
  // The owner is here: their browser says so as it opens the account. The
125
130
  // answer is the account's standing at this gateway, which the manage page
126
131
  // shows. A closed address is told so and nothing is written.
@@ -51,12 +51,16 @@ export async function provisionPublic(pod, base) {
51
51
  * The private half: the home itself and the state container.
52
52
  *
53
53
  * Idempotent, so two devices doing it at once is harmless — which is what
54
- * makes it safe to run on every boot rather than only at setup.
54
+ * makes it safe to run on every boot rather than only at setup. Asked before
55
+ * written: a browser's worker boots whenever the browser likes, and each boot
56
+ * used to write the canary and both rules again. Returns whether it wrote.
55
57
  */
56
58
  export async function provisionPrivate(pod, urls) {
59
+ if (await exists(pod, urls.state)) return false;
57
60
  await pod.putJson(keepUrl(urls.state), KEEP, KEEP_CT);
58
61
  await pod.setAcl(urls.state, []);
59
62
  await pod.setAcl(urls.home, []);
63
+ return true;
60
64
  }
61
65
 
62
66
  /**