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.
- package/lib/connections/bskyfeed.mjs +6 -0
- package/lib/core/deliver.mjs +52 -22
- package/lib/core/intake/index.mjs +17 -4
- package/lib/core/lease.mjs +7 -7
- package/lib/core/store.mjs +25 -5
- package/lib/gateway/caches.mjs +36 -0
- package/lib/gateway/front-core.mjs +29 -56
- package/lib/gateway/gateway-core.mjs +5 -2
- package/lib/gateway/headers.mjs +49 -0
- package/lib/gateway/quiet.mjs +7 -2
- package/lib/pod/containers.mjs +5 -1
- package/lib/pod/inbox.mjs +22 -3
- package/lib/pod/transport.mjs +36 -3
- package/lib/session/README.md +14 -0
- package/lib/session/fedi-account.mjs +62 -0
- package/lib/session/package.json +10 -2
- package/package.json +1 -1
- package/run-agent.mjs +16 -0
- package/web/app/agent.mjs +13 -8
- package/web/app/deliver-relay.mjs +47 -7
- package/web/app/dist/boot.js +36 -3
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +193 -43
- package/web/app/dist/sw.js.map +3 -3
- package/web/app/update.js +4 -0
|
@@ -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');
|
package/lib/core/deliver.mjs
CHANGED
|
@@ -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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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)
|
|
434
|
-
//
|
|
435
|
-
|
|
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.
|
package/lib/core/lease.mjs
CHANGED
|
@@ -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
|
|
13
|
-
// against a
|
|
14
|
-
//
|
|
15
|
-
// to the TTL — a user acting on a second device does not wait,
|
|
16
|
-
// claims the lease outright.
|
|
17
|
-
const TTL_MS =
|
|
18
|
-
const RENEW_MS =
|
|
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');
|
package/lib/core/store.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
...(
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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 !==
|
|
913
|
-
return json(
|
|
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,
|
|
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)
|
|
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:
|
|
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
|
+
|
package/lib/gateway/quiet.mjs
CHANGED
|
@@ -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'
|
|
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 (
|
|
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.
|
package/lib/pod/containers.mjs
CHANGED
|
@@ -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
|
/**
|