fedipod 1.36.6 → 1.40.0

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 (94) hide show
  1. package/README.md +29 -8
  2. package/architecture.md +8 -0
  3. package/bin/fedipod.mjs +6 -0
  4. package/cli.md +7 -1
  5. package/device-agent.md +3 -3
  6. package/gateway.md +69 -8
  7. package/groups.md +2 -1
  8. package/gui.md +3 -1
  9. package/lib/client/c2s.mjs +97 -43
  10. package/lib/client/masto/accounts.mjs +1 -0
  11. package/lib/client/masto/bridge.mjs +61 -0
  12. package/lib/client/masto/index.mjs +4 -1
  13. package/lib/client/masto/oauth.mjs +1 -1
  14. package/lib/client/masto/statuses.mjs +3 -0
  15. package/lib/connections/acctfeed.mjs +13 -9
  16. package/lib/connections/bskyfeed.mjs +13 -9
  17. package/lib/connections/tagfeed.mjs +14 -9
  18. package/lib/core/deliver.mjs +17 -1
  19. package/lib/core/intake/activities.mjs +18 -2
  20. package/lib/core/intake/group.mjs +3 -1
  21. package/lib/core/intake/index.mjs +226 -48
  22. package/lib/core/intake/notes.mjs +12 -5
  23. package/lib/core/intake/verify.mjs +11 -0
  24. package/lib/core/lease.mjs +15 -1
  25. package/lib/core/place.mjs +82 -0
  26. package/lib/core/pod-only.mjs +4 -0
  27. package/lib/core/publisher/collections.mjs +37 -2
  28. package/lib/core/publisher/index.mjs +25 -1
  29. package/lib/core/publisher/notes.mjs +73 -7
  30. package/lib/core/publisher/own.mjs +143 -0
  31. package/lib/core/publisher/questions.mjs +5 -3
  32. package/lib/core/scheduled.mjs +39 -0
  33. package/lib/core/social.mjs +62 -32
  34. package/lib/core/storage.mjs +67 -0
  35. package/lib/core/store.mjs +77 -5
  36. package/lib/core/wire.mjs +51 -15
  37. package/lib/device/admin/routes/lifecycle.mjs +1 -1
  38. package/lib/device/admin/routes/setup.mjs +17 -1
  39. package/lib/device/cli/commands/setup.mjs +50 -8
  40. package/lib/device/cli/context.mjs +1 -1
  41. package/lib/device/migrate.mjs +1 -1
  42. package/lib/device/setup.mjs +45 -8
  43. package/lib/gateway/account-agent.mjs +111 -0
  44. package/lib/gateway/copy.mjs +381 -0
  45. package/lib/gateway/front-core.mjs +74 -63
  46. package/lib/gateway/gateway-core.mjs +107 -9
  47. package/lib/gateway/held-mail.mjs +198 -0
  48. package/lib/gateway/keeper-due.mjs +39 -0
  49. package/lib/gateway/keeper-session.mjs +10 -0
  50. package/lib/gateway/keeper.mjs +72 -0
  51. package/lib/gateway/masto-gateway.mjs +510 -0
  52. package/lib/gateway/quiet.mjs +7 -2
  53. package/lib/gateway/relay-extras.mjs +89 -0
  54. package/lib/gateway/state-api.mjs +207 -0
  55. package/lib/gateway/token-claims.mjs +16 -0
  56. package/lib/pod/containers.mjs +17 -0
  57. package/lib/pod/location.mjs +52 -0
  58. package/lib/pod/notes.mjs +2 -4
  59. package/lib/pod/transport.mjs +214 -24
  60. package/lib/pod/type-index.mjs +101 -0
  61. package/lib/pod/urls.mjs +6 -0
  62. package/lib/server/embed.mjs +7 -6
  63. package/lib/session/README.md +5 -5
  64. package/lib/session/demo.html +1 -1
  65. package/lib/session/fedi-account.mjs +19 -10
  66. package/lib/session/package.json +2 -2
  67. package/package.json +2 -2
  68. package/run-agent.mjs +6 -16
  69. package/scripts/stage-site.mjs +17 -4
  70. package/web/admin/actors.js +2 -0
  71. package/web/admin/gateway.js +14 -1
  72. package/web/admin/index.html +13 -0
  73. package/web/admin/oauth-signin.mjs +1 -1
  74. package/web/admin/record.js +4 -1
  75. package/web/admin/setup/index.html +17 -1
  76. package/web/admin/setup/setup.js +18 -5
  77. package/web/app/README.md +2 -2
  78. package/web/app/admin-facade.mjs +12 -2
  79. package/web/app/agent.mjs +228 -51
  80. package/web/app/boot.mjs +75 -32
  81. package/web/app/copy-mode.mjs +223 -0
  82. package/web/app/dist/boot.js +498 -88
  83. package/web/app/dist/boot.js.map +4 -4
  84. package/web/app/dist/sw.js +4016 -2539
  85. package/web/app/dist/sw.js.map +4 -4
  86. package/web/app/index.html +16 -0
  87. package/web/app/signup.mjs +65 -26
  88. package/web/app/sw-src.mjs +15 -53
  89. package/web/app/update.js +3 -2
  90. package/web/app/warm-start.mjs +115 -0
  91. package/web/app-signin/app-signin.mjs +78 -0
  92. package/web/app-signin/index.html +41 -0
  93. package/web/front/run.html +7 -1
  94. package/web/front/run.js +30 -4
@@ -0,0 +1,381 @@
1
+ // copy.mjs — a kept account's working copy at the gateway.
2
+ //
3
+ // While the gateway keeps an account running, the account's state documents
4
+ // (everything under ap-state/) live here, and every agent works from this copy:
5
+ // the owner's browser (through the state API, state-api.mjs), the keeper and
6
+ // the gateway's Mastodon API (in process). The pod is written from it every
7
+ // fifteen minutes (flushCopy), and at once when the copy is given up
8
+ // (dropCopy). What only the pod may hold never comes here: the signing key,
9
+ // the pod's own lease document, and the passwords and tokens of accounts on
10
+ // other servers.
11
+ //
12
+ // The lease that decides which agent acts lives in the copy too, and the copy
13
+ // is what enforces it: a state document is written only by the lease's holder.
14
+ // So two agents working from one copy never write over each other; whichever
15
+ // acts takes the lease, as two browsers do today.
16
+ //
17
+ // `kv` is a small key-value store with conditional writes: Netlify Blobs with
18
+ // strong consistency on Netlify (netlify/functions/front.mjs), a Map in a test
19
+ // (memoryKv below).
20
+ // get(key) -> { text, etag } | null
21
+ // set(key, text, { ifMatch, ifNew }) -> { ok, etag }
22
+ // delete(key)
23
+ // list(prefix) -> [{ key, etag }]
24
+
25
+ import crypto from 'node:crypto';
26
+ import { Lease } from '../core/lease.mjs';
27
+ import { podOnly } from '../core/pod-only.mjs';
28
+
29
+ export { podOnly };
30
+ // The holder id every writer at the gateway shares: the keeper, the Mastodon
31
+ // API and the mail it reads for an app. They are kept apart by lockCopy().
32
+ export const GATEWAY_HOLDER = 'gateway';
33
+ // The pod lease while the copy exists, so an agent reading the pod directly
34
+ // finds the account busy and only reads. Held a day at a time and renewed by
35
+ // the round (renewPodLease): a copy that is lost frees the pod within a day.
36
+ const POD_HOLDER = 'gateway-copy';
37
+ const POD_LEASE_MS = 24 * 3600_000;
38
+ const POD_RENEW_BEFORE_MS = 6 * 3600_000;
39
+
40
+ // Keys are built from an account's directory key and a document's name, and
41
+ // the store puts a key into a URL as it stands, where `..` would climb out of
42
+ // the account. Only plain names are ever let through.
43
+ const SAFE_HANDLE = /^[a-z0-9][a-z0-9._@-]{0,200}$/u;
44
+ const SAFE_NAME = /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,200}\.json$/u;
45
+ export const safeName = (n) => typeof n === 'string' && SAFE_NAME.test(n) && !n.includes('..');
46
+ export const safeHandle = (h) => typeof h === 'string' && SAFE_HANDLE.test(h) && !h.includes('..');
47
+ function account(h) {
48
+ if (!safeHandle(h)) throw new Error(`not an account this gateway keeps: ${JSON.stringify(h)}`);
49
+ return h;
50
+ }
51
+ const docKey = (h, name) => {
52
+ if (!safeName(name)) throw new Error(`not a state document: ${JSON.stringify(name)}`);
53
+ return `${account(h)}/d/${name}`;
54
+ };
55
+ const hashOf = (s) => crypto.createHash('sha256').update(s).digest('base64url').slice(0, 22);
56
+
57
+ export function memoryKv() {
58
+ const m = new Map();
59
+ let n = 0;
60
+ return {
61
+ map: m,
62
+ async get(key) { return m.has(key) ? { ...m.get(key) } : null; },
63
+ async set(key, text, { ifMatch = null, ifNew = false } = {}) {
64
+ const had = m.get(key);
65
+ if (ifNew && had) return { ok: false };
66
+ if (ifMatch && had?.etag !== ifMatch) return { ok: false };
67
+ const etag = `"${++n}"`;
68
+ m.set(key, { text: String(text), etag });
69
+ return { ok: true, etag };
70
+ },
71
+ async delete(key) { m.delete(key); },
72
+ async list(prefix) { return [...m].filter(([k]) => k.startsWith(prefix)).map(([key, v]) => ({ key, etag: v.etag })); },
73
+ };
74
+ }
75
+
76
+ const readJson = async (kv, key) => {
77
+ const got = await kv.get(key);
78
+ if (!got) return { doc: null, etag: null };
79
+ try { return { doc: JSON.parse(got.text), etag: got.etag }; } catch { return { doc: null, etag: got.etag }; }
80
+ };
81
+
82
+ /**
83
+ * Whether the gateway keeps this account under the identity it has now. A row
84
+ * kept under a former identity (the operator changed FEDIPOD_KEEPER_*) names
85
+ * one the owner's rules no longer need to name; until the owner's FediPod
86
+ * hands it over (web/app/copy-mode.mjs: handOverCopy), the gateway leaves it
87
+ * alone.
88
+ */
89
+ export const keptNow = (ctx, rec) => !!(rec?.keeper && ctx.keeperWebId && (rec.keeper.webId || ctx.keeperWebId) === ctx.keeperWebId);
90
+ export const keptBefore = (ctx, rec) => !!(rec?.keeper?.webId && ctx.keeperWebId && rec.keeper.webId !== ctx.keeperWebId);
91
+
92
+ /**
93
+ * Forget a copy its owner has written to the pod themselves: no write-back, no
94
+ * pod lease to free (the owner took it). Asked only by the lease's holder.
95
+ */
96
+ export async function forgetCopy(kv, h, { log = () => {} } = {}) {
97
+ await kv.delete(`_copies/${account(h)}`);
98
+ await kv.delete(`${h}/meta`);
99
+ await kv.delete(`${h}/flushed`);
100
+ for (const b of await kv.list(`${h}/`)) await kv.delete(b.key);
101
+ log(`copy @${h}: forgotten; its owner wrote it to the pod`);
102
+ }
103
+
104
+ /** Every account the gateway keeps a copy for. */
105
+ export async function listCopies(kv) { return (await kv.list('_copies/')).map((b) => b.key.slice('_copies/'.length)); }
106
+
107
+ /** What the gateway knows of an account's copy, or null when it keeps none. */
108
+ export async function copyMeta(kv, h) { return (await readJson(kv, `${account(h)}/meta`)).doc; }
109
+
110
+ /**
111
+ * A fetch for one document in the copy, answering GET and PUT as a pod would
112
+ * (an ETag, If-Match, 412). The lease is read and written through it, so the
113
+ * Lease class works on the copy unchanged.
114
+ */
115
+ export function kvDocFetch(kv, key) {
116
+ return async (_url, init = {}) => {
117
+ const method = (init.method || 'GET').toUpperCase();
118
+ if (method === 'GET') {
119
+ const got = await kv.get(key);
120
+ if (!got) return new Response('', { status: 404 });
121
+ return new Response(got.text, { status: 200, headers: { 'content-type': 'application/json', etag: got.etag } });
122
+ }
123
+ if (method === 'PUT') {
124
+ const ifMatch = init.headers?.['if-match'] || init.headers?.get?.('if-match') || null;
125
+ const r = await kv.set(key, String(init.body ?? ''), ifMatch ? { ifMatch } : {});
126
+ if (!r.ok) return new Response('', { status: 412 });
127
+ return new Response(null, { status: 204, headers: { etag: r.etag } });
128
+ }
129
+ return new Response('', { status: 405 });
130
+ };
131
+ }
132
+
133
+ /** The lease on this account's copy, for a holder. */
134
+ export function copyLease(kv, h, { id, log = () => {} }) {
135
+ return new Lease({ url: `copy:${account(h)}/lease`, fetchImpl: kvDocFetch(kv, `${h}/lease`), log, id });
136
+ }
137
+
138
+ /** Whether `holder` holds the copy's lease now. */
139
+ export async function holds(kv, h, holder) {
140
+ const { doc } = await readJson(kv, `${account(h)}/lease`);
141
+ return !!doc && doc.holder === holder && Date.now() < doc.expiresAt;
142
+ }
143
+
144
+ /**
145
+ * Storage over the copy, for a PodStore. `holder` is who is writing, checked
146
+ * against the copy's lease on every write; `pod` is the account's state
147
+ * container on the pod (an HttpStorage), for what stays there.
148
+ */
149
+ export class CopyStorage {
150
+ // `podNames`: which of the pod-only documents to list (all, by default). The
151
+ // gateway's own agents need only the key.
152
+ constructor(kv, handle, { holder, pod = null, onRefused = null, podNames = null }) {
153
+ this.kv = kv;
154
+ this.h = account(handle);
155
+ this.holder = holder;
156
+ this.pod = pod;
157
+ this.onRefused = onRefused;
158
+ this.podNames = podNames;
159
+ }
160
+
161
+ get kind() { return 'copy'; }
162
+ get base() { return `copy:${this.h}/`; }
163
+
164
+ async list(sub = '', { etag } = {}) {
165
+ if (sub) return { notModified: false, names: [], etag: null };
166
+ const docs = (await this.kv.list(`${this.h}/d/`)).map((b) => ({ name: b.key.slice(`${this.h}/d/`.length), etag: b.etag }));
167
+ // What stays on the pod is listed only where it can be read.
168
+ const kept = this.pod ? ((await copyMeta(this.kv, this.h))?.podOnly || []).filter((n) => !this.podNames || this.podNames.includes(n)) : [];
169
+ const names = [...docs.map((d) => d.name), ...kept.filter((n) => n !== 'lease.json')];
170
+ const tag = `"${hashOf(JSON.stringify([docs.map((d) => `${d.name}:${d.etag}`).sort(), [...kept].sort()]))}"`;
171
+ if (etag && etag === tag) return { notModified: true, names: null, etag };
172
+ return { notModified: false, names, etag: tag };
173
+ }
174
+
175
+ async read(name, opts = {}) {
176
+ if (!safeName(name)) return { ok: false, notModified: false, status: 404, body: null, etag: null };
177
+ if (podOnly(name)) {
178
+ if (!this.pod) return { ok: false, notModified: false, status: 404, body: null, etag: null };
179
+ return this.pod.read(name, opts);
180
+ }
181
+ const got = await this.kv.get(docKey(this.h, name));
182
+ if (!got) return { ok: false, notModified: false, status: 404, body: null, etag: null };
183
+ if (opts.etag && opts.etag === got.etag) return { ok: true, notModified: true, status: 304, body: null, etag: got.etag };
184
+ return { ok: true, notModified: false, status: 200, body: got.text, etag: got.etag };
185
+ }
186
+
187
+ // A refusal is an answer, not a hiccup: the lease is someone else's.
188
+ async _fenced() {
189
+ if (await holds(this.kv, this.h, this.holder)) return null;
190
+ this.onRefused?.();
191
+ return { ok: false, retry: false, why: 'another agent holds this account now', lost: true };
192
+ }
193
+
194
+ async write(name, body) {
195
+ if (!safeName(name)) return { ok: false, retry: false, why: 'not a state document name' };
196
+ if (podOnly(name)) {
197
+ if (!this.pod) return { ok: false, retry: false, why: 'no pod for this document' };
198
+ return this.pod.write(name, body, 'application/json');
199
+ }
200
+ const refused = await this._fenced();
201
+ if (refused) return refused;
202
+ const r = await this.kv.set(docKey(this.h, name), body);
203
+ return r.ok ? { ok: true, retry: false, why: '', etag: r.etag } : { ok: false, retry: true, why: 'copy write failed' };
204
+ }
205
+
206
+ async remove(name) {
207
+ if (!safeName(name)) return false;
208
+ if (podOnly(name)) return this.pod ? this.pod.remove(name) : false;
209
+ if (await this._fenced()) return false;
210
+ await this.kv.delete(docKey(this.h, name));
211
+ return true;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * A short lock for the gateway's own writers on one account, so a keeper run,
217
+ * a post from an app and the mail read for an app take turns. Waits up to
218
+ * `waitMs`; returns the release function, or null when it could not get it.
219
+ * Held for `ms` at a time and extended while its holder runs, so a long run
220
+ * keeps it and a holder that died loses it within `ms`.
221
+ */
222
+ export async function lockCopy(kv, h, { ms = 60_000, waitMs = 8_000, by = crypto.randomUUID() } = {}) {
223
+ const key = `${account(h)}/lock`;
224
+ const give = (etag) => {
225
+ let tag = etag;
226
+ const timer = setInterval(async () => {
227
+ const r = await kv.set(key, JSON.stringify({ by, until: Date.now() + ms }), { ifMatch: tag }).catch(() => ({ ok: false }));
228
+ if (r.ok) { tag = r.etag; return; }
229
+ // Not extended: still ours means the store hiccuped, and the next tick
230
+ // tries again with what it holds now; anybody else's, it has gone.
231
+ const cur = await readJson(kv, key).catch(() => ({}));
232
+ if (cur.doc?.by === by) tag = cur.etag; else clearInterval(timer);
233
+ }, Math.max(1000, Math.floor(ms / 3)));
234
+ timer.unref?.();
235
+ return async () => {
236
+ clearInterval(timer);
237
+ const cur = await readJson(kv, key);
238
+ if (cur.doc?.by === by) await kv.delete(key);
239
+ };
240
+ };
241
+ const until = Date.now() + waitMs;
242
+ for (;;) {
243
+ const got = await kv.set(key, JSON.stringify({ by, until: Date.now() + ms }), { ifNew: true });
244
+ if (got.ok) return give(got.etag);
245
+ const cur = await readJson(kv, key);
246
+ // A lock whose holder died is taken over once its time is up.
247
+ if (cur.doc && Date.now() > cur.doc.until) {
248
+ const took = await kv.set(key, JSON.stringify({ by, until: Date.now() + ms }), { ifMatch: cur.etag });
249
+ if (took.ok) return give(took.etag);
250
+ }
251
+ if (Date.now() > until) return null;
252
+ await new Promise((r) => setTimeout(r, 150 + Math.random() * 150));
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Make the copy from the pod. `pod` is the account's state container, read
258
+ * with the keeper's identity; `podFetch` the same identity's fetch, for the
259
+ * pod's lease. Refused while an agent reading the pod directly is acting.
260
+ * Returns { ok } or { ok: false, why }; a copy half made is taken away again,
261
+ * and the pod's lease given back. The caller holds lockCopy for the account.
262
+ */
263
+ export async function fillCopy(kv, h, { pod, podFetch, stateUrl, webId = null, log = () => {} }) {
264
+ if (await copyMeta(kv, h)) return { ok: true, already: true };
265
+ const podLease = new Lease({ url: stateUrl + 'lease.json', fetchImpl: podFetch, log, id: POD_HOLDER });
266
+ if (!await podLease.acquire()) return { ok: false, why: 'the account is active on another device' };
267
+ const undo = async (why) => {
268
+ for (const b of await kv.list(`${h}/d/`).catch(() => [])) await kv.delete(b.key).catch(() => {});
269
+ await podLease.release().catch(() => {});
270
+ return { ok: false, why };
271
+ };
272
+ try {
273
+ // Whatever an interrupted give-up left behind is not this copy.
274
+ for (const b of await kv.list(`${h}/d/`)) await kv.delete(b.key);
275
+ const listing = await pod.list('');
276
+ if (listing.missing) return await undo('no account state on the pod');
277
+ const names = listing.names.filter(safeName);
278
+ const flushed = {};
279
+ // Six at a time: the whole state is read once, and quickly.
280
+ const wanted = names.filter((n) => !podOnly(n));
281
+ for (let i = 0; i < wanted.length; i += 6) {
282
+ const got = await Promise.all(wanted.slice(i, i + 6).map(async (name) => [name, await pod.read(name, { accept: 'application/json' })]));
283
+ const bad = got.find(([, r]) => !r.ok);
284
+ if (bad) return await undo(`${bad[0]} could not be read (HTTP ${bad[1].status})`);
285
+ for (const [name, r] of got) {
286
+ const set = await kv.set(docKey(h, name), r.body);
287
+ flushed[name] = { etag: set.etag, hash: hashOf(r.body) };
288
+ }
289
+ }
290
+ await kv.set(`${h}/flushed`, JSON.stringify(flushed));
291
+ const podLeaseUntil = Date.now() + POD_LEASE_MS;
292
+ await podLease.write({ holder: POD_HOLDER, expiresAt: podLeaseUntil });
293
+ await kv.set(`${h}/meta`, JSON.stringify({ filledAt: Date.now(), webId, stateUrl, podOnly: names.filter(podOnly), podLeaseUntil }));
294
+ await kv.set(`_copies/${h}`, String(Date.now()));
295
+ log(`copy @${h}: made from the pod (${Object.keys(flushed).length} documents)`);
296
+ return { ok: true };
297
+ } catch (e) {
298
+ return undo(e.message);
299
+ }
300
+ }
301
+
302
+ /** The pod's lease, held another day when this one is running out; the round asks. */
303
+ export async function renewPodLease(kv, h, { podFetch, log = () => {}, now = Date.now() } = {}) {
304
+ const { doc: meta, etag } = await readJson(kv, `${account(h)}/meta`);
305
+ if (!meta?.stateUrl || (meta.podLeaseUntil || 0) - now > POD_RENEW_BEFORE_MS) return false;
306
+ const podLeaseUntil = now + POD_LEASE_MS;
307
+ await new Lease({ url: meta.stateUrl + 'lease.json', fetchImpl: podFetch, log, id: POD_HOLDER })
308
+ .write({ holder: POD_HOLDER, expiresAt: podLeaseUntil });
309
+ await kv.set(`${h}/meta`, JSON.stringify({ ...meta, podLeaseUntil }), etag ? { ifMatch: etag } : {});
310
+ return true;
311
+ }
312
+
313
+ /**
314
+ * Write what changed in the copy to the pod. Returns how many documents were
315
+ * written or removed; a document the pod refused is tried again next time.
316
+ *
317
+ * What the pod holds is recorded per document as the copy's version of it and
318
+ * a hash of its content: a document whose version is unchanged is not read at
319
+ * all, and one whose content is unchanged is not sent.
320
+ */
321
+ export async function flushCopy(kv, h, { pod, log = () => {} }) {
322
+ const docs = (await kv.list(`${account(h)}/d/`)).filter((b) => safeName(b.key.slice(`${h}/d/`.length)));
323
+ const { doc: had, etag: flushedEtag } = await readJson(kv, `${h}/flushed`);
324
+ const flushed = { ...(had || {}) };
325
+ let n = 0;
326
+ let changed = false;
327
+ for (const b of docs) {
328
+ const name = b.key.slice(`${h}/d/`.length);
329
+ if (flushed[name]?.etag === b.etag) continue;
330
+ const got = await kv.get(b.key);
331
+ if (!got) continue;
332
+ const hash = hashOf(got.text);
333
+ if (flushed[name]?.hash !== hash) {
334
+ const w = await pod.write(name, got.text, 'application/json');
335
+ if (!w.ok) { log(`copy @${h}: ${name} not written to the pod (${w.why})`); continue; }
336
+ n++;
337
+ }
338
+ flushed[name] = { etag: b.etag, hash };
339
+ changed = true;
340
+ }
341
+ const present = new Set(docs.map((b) => b.key.slice(`${h}/d/`.length)));
342
+ for (const name of Object.keys(flushed)) {
343
+ if (present.has(name)) continue;
344
+ if (await pod.remove(name)) { delete flushed[name]; n++; changed = true; }
345
+ }
346
+ if (changed) {
347
+ const saved = await kv.set(`${h}/flushed`, JSON.stringify(flushed), flushedEtag ? { ifMatch: flushedEtag } : {});
348
+ // Another flush got there first: its record stands, and anything this one
349
+ // wrote twice is written again next time, which is harmless.
350
+ if (!saved.ok) log(`copy @${h}: another flush recorded first`);
351
+ }
352
+ if (n) log(`copy @${h}: ${n} document(s) written to the pod`);
353
+ return n;
354
+ }
355
+
356
+ /**
357
+ * Give the copy up: everything written to the pod, the pod's lease freed, the
358
+ * copy deleted. Returns { ok } or { ok: false, why } when the pod would not
359
+ * take everything or its lease could not be freed, in which case nothing is
360
+ * deleted. The record of what the pod holds goes first, so a give-up cut off
361
+ * part way can never lead a later write-back to remove the pod's documents.
362
+ */
363
+ export async function dropCopy(kv, h, { pod, podFetch, stateUrl, log = () => {} }) {
364
+ const meta = await copyMeta(kv, h);
365
+ if (!meta) return { ok: true, already: true };
366
+ await flushCopy(kv, h, { pod, log });
367
+ const docs = await kv.list(`${h}/d/`);
368
+ const { doc: flushed } = await readJson(kv, `${h}/flushed`);
369
+ const behind = docs.filter((b) => flushed?.[b.key.slice(`${h}/d/`.length)]?.etag !== b.etag);
370
+ if (behind.length) return { ok: false, why: `${behind.length} document(s) could not be written to the pod` };
371
+ const podLease = new Lease({ url: (stateUrl || meta.stateUrl) + 'lease.json', fetchImpl: podFetch, log, id: POD_HOLDER });
372
+ try { await podLease.write({ holder: POD_HOLDER, expiresAt: 0 }); } catch (e) {
373
+ return { ok: false, why: `the pod's lease could not be freed (${e.message})` };
374
+ }
375
+ await kv.delete(`_copies/${h}`);
376
+ await kv.delete(`${h}/meta`);
377
+ await kv.delete(`${h}/flushed`);
378
+ for (const b of await kv.list(`${h}/`)) await kv.delete(b.key);
379
+ log(`copy @${h}: given up; the pod holds everything`);
380
+ return { ok: true };
381
+ }
@@ -21,10 +21,13 @@ import { readCapped, safeFetch, isLoopbackHost } from '../shared/safefetch.mjs';
21
21
  import * as podRoot from '../pod/root.mjs';
22
22
  import * as podPolicy from '../pod/policy.mjs';
23
23
  import { podBaseOfWebId } from '../pod/urls.mjs';
24
- import { routeQuietApi, noteOpened, noteReceived, closedState, accountState, closedAnswer } from './quiet.mjs';
24
+ import { routeQuietApi, noteOpened, noteReceived, closedState, accountState, closedAnswer, provedOwner } from './quiet.mjs';
25
+ import { holdsMail, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi } from './held-mail.mjs';
25
26
  import { routeNoticesApi } from './notices.mjs';
26
27
  import { withSecurityHeaders } from './headers.mjs';
27
28
  import { podTokenVerifier } from './caches.mjs';
29
+ import { claimedWebId } from './token-claims.mjs';
30
+ import { announced, overLimit, relayOne } from './relay-extras.mjs';
28
31
 
29
32
  // The one WebFinger document, spelled out here rather than imported from
30
33
  // wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
@@ -55,7 +58,7 @@ const AP_CT = 'application/activity+json';
55
58
  // Verify a Solid-OIDC token (DPoP-bound) and return its WebID, or null. The
56
59
  // verifier is injected so tests stub it; in production it is the same library
57
60
  // the agent's own C2S auth uses.
58
- async function verifyPodToken(request, pathname, verifier) {
61
+ export async function verifyPodToken(request, pathname, verifier) {
59
62
  const authz = request.headers.get('authorization');
60
63
  if (!authz) return null;
61
64
  try {
@@ -91,9 +94,13 @@ const podOwners = (podBase, fetchImpl = fetch) =>
91
94
  podRoot.readOwnerLinks(fetchImpl, podBase, { timeoutMs: OWNER_LOOKUP_MS });
92
95
 
93
96
  // Whether a token's WebID vouches for this pod, on the evidence of where it
94
- // lives. Weak on purpose, and never used alone any more — see provesPod.
97
+ // lives: the same origin, and — for a suffixed pod — inside
98
+ // the WebID's own pod, wherever in it the account was put. Weak on purpose,
99
+ // and never used alone for an attach — see provesPod.
95
100
  function webidUnderPod(webid, podHome) {
96
- try { return new URL(webid).origin === new URL(podHome).origin; } catch { return false; }
101
+ try {
102
+ return new URL(webid).origin === new URL(podHome).origin && String(podHome).startsWith(podBaseOfWebId(webid));
103
+ } catch { return false; }
97
104
  }
98
105
 
99
106
  /**
@@ -144,15 +151,8 @@ function podHomeProblem(podHome, frontOrigin) {
144
151
  }
145
152
 
146
153
  const RELAY_MAX_REQUESTS = 20;
147
- const RELAY_MAX_BODY = 1024 * 1024;
148
- const RELAY_TIMEOUT_MS = 8_000;
149
- // The only headers a relayed request may carry to the remote server. Host
150
- // comes from the URL; the user agent from safeFetch.
151
154
  const SERVER_PROBE_MS = 6000;
152
155
  const SERVER_PROBE_BYTES = 64 * 1024;
153
- const RELAY_HEADERS = new Set(['date', 'digest', 'signature', 'content-type', 'accept']);
154
-
155
- const keyIdOf = (signature) => (/keyId="([^"]+)"/.exec(signature || '') || [])[1] || null;
156
156
 
157
157
  // The owner's APIs a browser at ANOTHER origin may call: an address moving
158
158
  // to a new gateway is driven from the new gateway's page, and it has to tell
@@ -167,46 +167,6 @@ const API_CORS = {
167
167
  const withApiCors = (out) => ({ ...out, headers: { ...(out.headers || {}), ...API_CORS } });
168
168
  const apiPreflight = () => ({ status: 204, headers: { ...API_CORS, allow: 'POST, OPTIONS' }, body: null });
169
169
 
170
- async function relayOne(item, rec, fetchImpl) {
171
- const url = String(item?.url || '');
172
- const method = String(item?.method || 'POST').toUpperCase();
173
- if (method !== 'GET' && method !== 'POST') return { url, status: 0, error: 'method must be GET or POST' };
174
- let u;
175
- try { u = new URL(url); } catch { return { url, status: 0, error: 'not a URL' }; }
176
- if (u.protocol !== 'https:' && !(u.protocol === 'http:' && process.env.AP_ALLOW_PRIVATE_TARGETS === '1')) {
177
- return { url, status: 0, error: 'https only' };
178
- }
179
- const headers = {};
180
- for (const [k, v] of Object.entries(item?.headers || {})) {
181
- const name = k.toLowerCase();
182
- if (RELAY_HEADERS.has(name) && typeof v === 'string') headers[name] = v;
183
- }
184
- const body = method === 'POST' ? String(item?.body ?? '') : undefined;
185
- if (body !== undefined && Buffer.byteLength(body) > RELAY_MAX_BODY) return { url, status: 0, error: 'body too large' };
186
- const keyId = keyIdOf(headers.signature);
187
- if (method === 'POST' && !keyId) return { url, status: 0, error: 'a delivery must be signed' };
188
- if (keyId && !keyId.startsWith(rec.actorUrl + '#')) return { url, status: 0, error: "signed with a key that is not this account's" };
189
- if (headers.digest) {
190
- const want = 'SHA-256=' + crypto.createHash('sha256').update(body || '').digest('base64');
191
- if (headers.digest !== want) return { url, status: 0, error: 'digest does not match the body' };
192
- }
193
- try {
194
- const res = await safeFetch(url, { method, headers, body, signal: AbortSignal.timeout(RELAY_TIMEOUT_MS) }, fetchImpl);
195
- const out = { url, method, status: res.status };
196
- // The far server asking to be left alone has to reach the agent that will
197
- // do the asking again. Without this the browser build could not honour a
198
- // Retry-After at all — every delivery it makes goes through here — and fell
199
- // back to its own ladder against a server that had already said how long.
200
- const retryAfter = res.headers.get('retry-after');
201
- if (retryAfter) out.retryAfter = retryAfter;
202
- if (method === 'GET') {
203
- out.contentType = res.headers.get('content-type') || null;
204
- out.body = await readCapped(res, RELAY_MAX_BODY);
205
- }
206
- return out;
207
- } catch (e) { return { url, method, status: 0, error: e.message }; }
208
- }
209
-
210
170
  const j = (status, obj, ct = 'application/json') =>
211
171
  ({ status, headers: { 'content-type': ct, 'cache-control': 'no-store' }, body: JSON.stringify(obj) });
212
172
  // Held at the edge like any other missing document: a bot's scan, a dead
@@ -257,6 +217,11 @@ const publicFor = (seconds, stale = seconds * 4) => ({
257
217
  // acts, and ten minutes of edge is what the plan's budget of function calls
258
218
  // can afford.
259
219
  const PUBLIC_EDGE_SECONDS = 600;
220
+ // A browser account's documents change only when it acts, and it acts through
221
+ // the relay, which clears the edge's copies (see /api/relay). So they are held
222
+ // for an hour: the hour bounds only what changes without an announcement, a
223
+ // like or a reply counted on one of its posts.
224
+ const ANNOUNCED_EDGE_SECONDS = 3600;
260
225
  const WEBFINGER_EDGE_SECONDS = 3600;
261
226
  // A closed or moved address stays gone; a picture's address stays where it is.
262
227
  const GONE_EDGE_SECONDS = 3600;
@@ -615,6 +580,10 @@ async function route(request, ctx) {
615
580
  // The owner's say over an account that goes quiet (quiet.mjs).
616
581
  const quiet = await routeQuietApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod, apiPreflight });
617
582
  if (quiet) return withApiCors(quiet);
583
+ // The owner's app saying it is open, and taking what was held (held-mail.mjs).
584
+ const here = await routeHereApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod, apiPreflight, provedOwner })
585
+ || await routeKeeperApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod, apiPreflight, provedOwner });
586
+ if (here) return withApiCors(here);
618
587
 
619
588
  // The relay: the front sends requests a browser has already signed. A page
620
589
  // may not set the Date or Host header, and both are inside an HTTP
@@ -639,7 +608,11 @@ async function route(request, ctx) {
639
608
  // Acting through the relay is being here. Hourly at most; see noteOpened.
640
609
  await noteOpened(ctx, handle, rec).catch((e) => console.log(`relay @${handle}: stamp not written: ${e?.message || e}`));
641
610
  if (items.length > RELAY_MAX_REQUESTS) return withApiCors(j(400, { error: `at most ${RELAY_MAX_REQUESTS} requests per call` }));
611
+ const slow = overLimit(handle, items.length) && j(429, { error: 'too many relayed requests — slow down' });
612
+ if (slow) return withApiCors({ ...slow, headers: { ...slow.headers, 'retry-after': '60' } });
642
613
  const results = await Promise.all(items.map((it) => relayOne(it, rec, ctx.fetchImpl || fetch)));
614
+ // The account announced something of its own: the edge's copies go.
615
+ if (announced(items, rec.actorUrl)) await ctx.purge?.([`u-${handle}`]).catch((e) => console.log(`purge @${handle}: ${e?.message || e}`));
643
616
  // One line per relayed request, so a lookup that fails on the far side is
644
617
  // visible here and not only as an empty result in someone's browser.
645
618
  for (const r of results) console.log(`relay @${handle}: ${r.method || ''} ${r.url} → ${r.status}${r.error ? ` (${r.error})` : ''}`);
@@ -724,7 +697,8 @@ async function route(request, ctx) {
724
697
  return j(403, { error: 'the token proves a different pod than the one you listed' });
725
698
  }
726
699
  if (action === 'opt-in') {
727
- const { httpStatus, ...reply } = await ctx.agentControl.optIn({ podBase, webId: webid });
700
+ const { httpStatus, ...reply } = await ctx.agentControl.optIn({ podBase, webId: webid,
701
+ container: String(body.container || ''), createIndex: body.createIndex === true });
728
702
  if (reply.doorSecret) {
729
703
  // The secret appears here and nowhere else; the command is the
730
704
  // paste-and-run way to open the owner door once.
@@ -833,11 +807,15 @@ async function route(request, ctx) {
833
807
  if (closed) { console.log(`door @${up.handle}: delivery → 410 (closed)`); return gone({}, CLOSED); }
834
808
  const policy = await policyFor(rec, ctx.fetchImpl || fetch);
835
809
  const standing = await accountState(ctx, up.handle, rec);
836
- const { status, reason, content, bytes } = await handleDelivery(request, identFor(rec, policy),
837
- { podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), fetchImpl: ctx.fetchImpl, paused: standing.paused });
810
+ // A browser account's mail waits here while its app is closed (held-mail.mjs).
811
+ const held = holdsMail(ctx, rec) && !(await isPresent(ctx, up.handle));
812
+ const { status, reason, content, bytes, type, notifies } = await handleDelivery(request, identFor(rec, policy),
813
+ { podPut: held ? holdingPut(ctx, up.handle, rec) : (u, b, ct) => ctx.podPut(up.handle, u, b, ct),
814
+ fetchImpl: ctx.fetchImpl, paused: standing.paused });
838
815
  // One line per delivery, so "did it arrive at the door" has an answer.
839
- console.log(`door @${up.handle}: delivery → ${status} (${reason})`);
816
+ console.log(`door @${up.handle}: delivery → ${status} (${reason}${held && status === 202 ? ', held' : ''})`);
840
817
  if (status === 202 && content) await noteReceived(ctx, up.handle, rec, bytes);
818
+ if (held && status === 202) await afterHeld(ctx, up.handle, rec, { type, notifies });
841
819
  return { status, headers: {}, body: '' };
842
820
  }
843
821
 
@@ -876,13 +854,28 @@ async function route(request, ctx) {
876
854
  if (!webid) return json(401, { error: 'a Solid-OIDC token proving this account\'s owner is required' });
877
855
  const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
878
856
  if (!owner) return json(403, { error: 'this outbox belongs to its owner alone' });
879
- const { status, reason, location } = await handleOwnerPost(request, identFor(rec),
880
- { podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), ownerWebId: webid });
857
+ const onPod = (u) => u.replace(rec.actorUrl.replace(/ap\/actor$/u, ''), rec.podHome);
858
+ const { status, reason, location, object } = await handleOwnerPost(request, identFor(rec),
859
+ { podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), ownerWebId: webid,
860
+ exists: async (u) => ((await (ctx.podGet || fetch)(onPod(u), { podHome: rec.podHome })).status !== 404) });
881
861
  console.log(`door @${up.handle}: owner post → ${status} (${reason})`);
882
862
  if (status !== 201) return json(status, { error: reason });
883
- return json(201, { accepted: true, ...(location ? { object: location } : {}),
863
+ return json(201, { accepted: true, ...(location ? { id: location } : {}), ...(object ? { object } : {}),
884
864
  note: 'it goes out when your FediPod agent next runs' }, location ? { location } : {});
885
865
  }
866
+ // Its owner, signed in, reads every message the account produced (§5.1:
867
+ // the outbox is filtered by who asks); that view is on the pod, under the
868
+ // owner's own rule. Anyone else reads the public one.
869
+ // The token is only read here, not verified: the redirect grants nothing,
870
+ // the pod checks the credential when the owner arrives there, and a
871
+ // verification per read would let anyone make the front fetch keys from a
872
+ // server they name.
873
+ if ((request.method === 'GET' || request.method === 'HEAD') && request.headers.get('authorization')) {
874
+ const webid = claimedWebId(request);
875
+ if (webid && (rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome))) {
876
+ return { status: 303, headers: { ...cors, location: rec.podHome + 'ap/private/outbox', 'cache-control': 'no-store' }, body: '' };
877
+ }
878
+ }
886
879
  if (rec.inboxOnly && (request.method === 'GET' || request.method === 'HEAD')) {
887
880
  return { status: 303, headers: { ...cors, location: rec.podHome + 'ap/outbox', 'cache-control': 'no-store' }, body: '' };
888
881
  }
@@ -907,6 +900,12 @@ async function route(request, ctx) {
907
900
  }
908
901
  if (closed) return gone({ ...open, ...publicFor(GONE_EDGE_SECONDS) }, CLOSED);
909
902
  const podTarget = rec.podHome + up.rest;
903
+ // The owner's own documents — the full outbox, the liked list, the pending
904
+ // lists — are read at the pod, which decides who may; never held here,
905
+ // where one reader's answer would be served to the next.
906
+ if (up.rest.startsWith('ap/private/')) {
907
+ return { status: 303, headers: { ...open, location: podTarget, 'cache-control': 'no-store' }, body: '' };
908
+ }
910
909
  // Media stays on the pod (lib/pod/urls.mjs keeps `media` off the front), but
911
910
  // the id rewrite below turns media links onto the front like every other
912
911
  // pod url in a document. Answer those by pointing at the pod: bytes are not
@@ -929,8 +928,9 @@ async function route(request, ctx) {
929
928
  ctx.podGet || ((u) => fetch(u, { headers: { accept } })),
930
929
  podTarget, { podHome: rec.podHome, accept });
931
930
  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: '' };
931
+ const hold = [401, 403, 404, 410].includes(got.status) ? { ...publicFor(MISSING_EDGE_SECONDS), 'netlify-cache-tag': `u-${up.handle}` } : {};
932
+ // A stranger is not told a document exists that they may not read (§3.2).
933
+ return { status: got.status === 401 || got.status === 403 ? 404 : got.status, headers: { ...open, ...hold }, body: '' };
934
934
  }
935
935
  let text = got.text;
936
936
  text = swap(text, rec.podHome, base);
@@ -946,6 +946,9 @@ async function route(request, ctx) {
946
946
  doc.preferredUsername = up.handle; // so @handle@front cross-checks
947
947
  doc.inbox = base + 'ap/inbox/'; // deliveries come to the front to be verified
948
948
  doc.endpoints = { ...(doc.endpoints || {}), sharedInbox: base + 'ap/inbox/' };
949
+ // The owner's own documents are read at the pod, where their credential
950
+ // works; they keep the pod's address.
951
+ for (const k of ['liked', 'ownerOutbox']) if (typeof doc[k] === 'string') doc[k] = doc[k].replace(base, rec.podHome);
949
952
  if (movedBase) {
950
953
  doc.movedTo = rec.movedTo;
951
954
  const aka = new Set([].concat(doc.alsoKnownAs || []).filter((a) => typeof a === 'string' && a !== doc.id));
@@ -961,9 +964,17 @@ async function route(request, ctx) {
961
964
  } catch { /* leave the rewritten text as-is if it will not parse */ }
962
965
  }
963
966
  // `readPublicDocument` asked for it with no credential, so a document that
964
- // came back is one anybody can read — which is what makes it cacheable.
965
- return { status: 200,
966
- headers: { ...open, 'content-type': got.type || (container ? 'text/turtle' : page ? 'text/html' : AP_CT), ...publicFor(PUBLIC_EDGE_SECONDS) },
967
+ // came back is one anybody can read — which is what makes it cacheable. A
968
+ // deleted post's Tombstone answers 410 Gone, and still says what it was.
969
+ let kind = null;
970
+ try { kind = JSON.parse(text)?.type; } catch { /* not JSON: a page or a container */ }
971
+ return { status: kind === 'Tombstone' ? 410 : 200,
972
+ headers: { ...open, 'content-type': got.type || (container ? 'text/turtle' : page ? 'text/html' : AP_CT),
973
+ ...publicFor(rec.openedAt ? ANNOUNCED_EDGE_SECONDS : PUBLIC_EDGE_SECONDS),
974
+ // The outbox answers its signed-in owner differently, so the edge keeps
975
+ // the public copy for those who show no credential.
976
+ ...(up.rest === 'ap/outbox' ? { 'netlify-vary': 'header=Authorization', vary: 'Authorization' } : {}),
977
+ 'netlify-cache-tag': `u-${up.handle}` },
967
978
  body: text };
968
979
  }
969
980