fedipod 1.44.0 → 1.47.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 (42) hide show
  1. package/README.md +8 -13
  2. package/architecture.md +5 -2
  3. package/gateway.md +56 -24
  4. package/gui.md +3 -2
  5. package/lib/client/c2s.mjs +23 -74
  6. package/lib/client/masto/index.mjs +5 -4
  7. package/lib/client/masto/render.mjs +4 -1
  8. package/lib/core/intake/activities.mjs +8 -9
  9. package/lib/core/intake/counts.mjs +143 -0
  10. package/lib/core/intake/index.mjs +12 -1
  11. package/lib/core/intake/received.mjs +78 -0
  12. package/lib/core/publisher/notes.mjs +4 -1
  13. package/lib/core/social.mjs +2 -0
  14. package/lib/core/storage.mjs +54 -5
  15. package/lib/core/wire.mjs +16 -5
  16. package/lib/gateway/account-agent.mjs +20 -4
  17. package/lib/gateway/copy.mjs +95 -5
  18. package/lib/gateway/front-core.mjs +18 -9
  19. package/lib/gateway/gateway-core.mjs +46 -14
  20. package/lib/gateway/held-mail.mjs +11 -4
  21. package/lib/gateway/httpsig.mjs +6 -1
  22. package/lib/gateway/keeper.mjs +9 -6
  23. package/lib/gateway/masto-gateway.mjs +140 -40
  24. package/lib/gateway/private-read.mjs +98 -0
  25. package/lib/gateway/quiet.mjs +4 -3
  26. package/lib/gateway/state-api.mjs +73 -18
  27. package/lib/pod/notes.mjs +25 -0
  28. package/lib/pod/urls.mjs +3 -0
  29. package/lib/server/embed.mjs +25 -0
  30. package/lib/surface/routes/owner.mjs +2 -2
  31. package/package.json +2 -2
  32. package/web/admin/gateway.js +8 -24
  33. package/web/admin/index.html +0 -16
  34. package/web/admin/record.js +1 -2
  35. package/web/admin/upkeep.js +1 -5
  36. package/web/app/admin-facade.mjs +1 -1
  37. package/web/app/agent.mjs +27 -10
  38. package/web/app/copy-mode.mjs +98 -13
  39. package/web/app/dist/boot.js +12 -3
  40. package/web/app/dist/boot.js.map +2 -2
  41. package/web/app/dist/sw.js +582 -282
  42. package/web/app/dist/sw.js.map +4 -4
@@ -6,7 +6,9 @@
6
6
  // app is closed nobody reads it, so the door keeps it here instead
7
7
  // (ctx.holdMail), and writes it to the pod inbox as one document when the app
8
8
  // says it is open (POST /api/here) or when the timer comes round (flushAll).
9
- // While the app is open, mail goes straight to the pod as it always has.
9
+ // While the app is open, mail goes straight to the pod as it always has. With
10
+ // the hold off (copy.mjs: FEDIPOD_HOLD), nothing is held: mail goes straight
11
+ // to the pod whether the app is open or not.
10
12
  //
11
13
  // A batch is plain JSON, `{ "batch": [ { name, body, receipt } ] }`: `name` is
12
14
  // what the item would have been called in the inbox, `body` its bytes, and
@@ -23,7 +25,7 @@
23
25
  // presentAt(handle) -> ms when it last said so
24
26
  // noteNext(handle, at, {earliest}) when the account's keeper next has work
25
27
 
26
- import { copyMeta, keptNow } from './copy.mjs';
28
+ import { copyMeta, keptNow, holdOn } from './copy.mjs';
27
29
 
28
30
  // The app says it is open every five minutes; a little over that counts.
29
31
  export const PRESENT_MS = 8 * 60_000;
@@ -37,7 +39,11 @@ const inboxOf = (rec) => rec.inboxUrl || rec.podHome + 'ap/inbox/';
37
39
  // Only a browser account is held: its owner signs in (so the row has
38
40
  // `openedAt`), and its app says when it is open. An account run by a DeviceAgent
39
41
  // drains whenever mail lands, and keeps its mail as it always has.
40
- export const holdsMail = (ctx, rec) => !!(ctx.holdMail && rec?.openedAt);
42
+ export const holdsMail = (ctx, rec) => !!(ctx.holdMail && rec?.openedAt && holdOn(ctx));
43
+ // A browser account whose app may be closed: its mail is held, or with the
44
+ // hold off, sent to the pod, and either way a follow or a notification starts
45
+ // the work that answers it (afterHeld).
46
+ export const mayBeAway = (ctx, rec) => !!(ctx.holdMail && rec?.openedAt);
41
47
 
42
48
  // Asked on every delivery, so it is remembered a minute per running copy.
43
49
  const seen = new Map(); // handle -> { at, asked }
@@ -204,5 +210,6 @@ export async function routeHereApi(request, pathname, ctx, deps) {
204
210
  if (body.nextAt !== undefined && ctx.noteNext) await ctx.noteNext(handle, body.nextAt || null).catch(() => {});
205
211
  const flushed = await flushHeld(ctx, handle, rec)
206
212
  .catch((e) => { console.log(`here @${handle}: held mail not delivered: ${e?.message || e}`); return 0; });
207
- return j(200, { ok: true, handle, flushed });
213
+ // `hold`: with it off, a browser working from a copy goes back to the pod.
214
+ return j(200, { ok: true, handle, flushed, hold: holdOn(ctx) });
208
215
  }
@@ -49,14 +49,16 @@ export function makeSafeLoader({ getActors = null, fetchImpl = fetch } = {}) {
49
49
  export async function verifyHttpSignature(request, { documentLoader, keyCache, timeWindow } = {}) {
50
50
  const hadSig = request.headers.get('signature') != null;
51
51
  let result = null;
52
+ let threw = null;
52
53
  try {
53
54
  result = await verifyRequestDetailed(request, {
54
55
  documentLoader,
55
56
  ...(keyCache ? { keyCache } : {}),
56
57
  timeWindow: timeWindow ?? { hours: 1 },
57
58
  });
58
- } catch {
59
+ } catch (e) {
59
60
  result = null; // the library itself failed — no more informative than a bad signature
61
+ threw = e?.message || String(e);
60
62
  }
61
63
  // Which signature the sender used: RFC 9421 carries a Signature-Input
62
64
  // header beside Signature; the cavage draft carries Signature alone. The
@@ -83,9 +85,12 @@ export async function verifyHttpSignature(request, { documentLoader, keyCache, t
83
85
  reason: 'key-unfetchable', checks: { signature: false, keyFetched: false },
84
86
  };
85
87
  }
88
+ // `why` is for the log line, not the receipt: the library's own word for
89
+ // the failure, or that it threw.
86
90
  return {
87
91
  verified: false, method, keyId: null, actor: null,
88
92
  reason: 'bad-signature', checks: { signature: false },
93
+ why: threw ? `library threw: ${threw.split('\n')[0].slice(0, 120)}` : (kind || 'unknown'),
89
94
  };
90
95
  }
91
96
 
@@ -18,7 +18,7 @@ import { keeperSession } from './keeper-session.mjs';
18
18
  import { flushHeld, heldEntries } from './held-mail.mjs';
19
19
  import { publishDue, nextDue } from '../core/scheduled.mjs';
20
20
  import { lockCopy } from './copy.mjs';
21
- import { reachAccount, stateAndLease, actingAgent } from './account-agent.mjs';
21
+ import { reachAccount, settle, stateAndLease, actingAgent } from './account-agent.mjs';
22
22
 
23
23
  export { keeperSession };
24
24
 
@@ -27,10 +27,11 @@ export { keeperSession };
27
27
  * keeperCredential and the held-mail stores. `session` may be handed in (a
28
28
  * test does); otherwise the keeper's grant is used. Returns what happened.
29
29
  *
30
- * An account whose copy the gateway keeps (copy.mjs) is worked on there: its
31
- * held mail is read straight into the copy, and the pod is written by the
32
- * round, not here. Otherwise the held mail goes to the pod inbox first, so the
33
- * drain reads it, as before.
30
+ * While the hold is on, the account is worked on in its copy (copy.mjs), made
31
+ * from the pod if there is none: its held mail is read straight into the
32
+ * copy, and the pod is written by the round, not here. With the hold off, it
33
+ * is worked on in the pod, and any held mail goes to the pod inbox first, so
34
+ * the drain reads it.
34
35
  */
35
36
  export async function keepOnce(ctx, handle, rec, { log = console.log, session = null } = {}) {
36
37
  // A forum, or one of its categories, is run whole by the forum's own code
@@ -40,9 +41,11 @@ export async function keepOnce(ctx, handle, rec, { log = console.log, session =
40
41
  const at = await reachAccount(ctx, handle, rec, { log, session });
41
42
  if (at.skipped) return { skipped: at.skipped };
42
43
 
43
- const unlock = at.inCopy ? await lockCopy(ctx.copyKv, handle) : async () => {};
44
+ const unlock = ctx.copyKv ? await lockCopy(ctx.copyKv, handle) : async () => {};
44
45
  if (!unlock) return { skipped: 'the gateway is busy with this account', retry: 'soon' };
45
46
  try {
47
+ const settled = await settle(ctx, handle, rec, at, { log });
48
+ if (!settled.ok) return { skipped: `the account's copy could not be made: ${settled.why}`, retry: 'soon' };
46
49
  // Held mail goes in first, so this drain reads it.
47
50
  const flushed = !at.inCopy && ctx.listHeld
48
51
  ? await flushHeld(ctx, handle, rec).catch((e) => { say(`held mail not delivered: ${e.message}`); return 0; }) : 0;
@@ -1,6 +1,7 @@
1
1
  // masto-gateway.mjs — Mastodon apps at the gateway's own address: elk.zone,
2
2
  // Ivory, Tusky or any other app, signed in to an account the gateway keeps
3
- // running, reading and writing it from its copy (copy.mjs).
3
+ // running, reading and writing it from its copy (copy.mjs), made from the pod
4
+ // when there is none while the hold is on; with the hold off, from the pod.
4
5
  //
5
6
  // POST /api/v1/apps an app registers here, for every account
6
7
  // GET /oauth/authorize sends the person to the sign-in page
@@ -20,7 +21,8 @@
20
21
  // browser sitting open, as one device does another — and reads the signing
21
22
  // key from the pod for that one request. Mail that arrived while nobody was
22
23
  // reading is read into the copy after an app's timeline or notification check
23
- // has been answered, at most every thirty seconds.
24
+ // has been answered, at most every thirty seconds; with the hold off, the pod
25
+ // inbox is drained then instead, unless FediPod is open and drains it itself.
24
26
  //
25
27
  // Phone notifications: an app signs up for them through the account's own
26
28
  // facade (/api/v1/push/subscription), with the one push key pair this gateway
@@ -37,10 +39,13 @@ import { PodStore } from '../core/store.mjs';
37
39
  import { apUrls } from '../core/wire.mjs';
38
40
  import { Publisher } from '../core/publisher/index.mjs';
39
41
  import { nextDue } from '../core/scheduled.mjs';
40
- import { reachAccount, stateAndLease, actingAgent } from './account-agent.mjs';
42
+ import { reachAccount, settle, stateAndLease, actingAgent } from './account-agent.mjs';
41
43
  import { ensureCopy } from './state-api.mjs';
42
- import { CopyStorage, lockCopy, keptNow, keptBefore, GATEWAY_HOLDER } from './copy.mjs';
44
+ import { CopyStorage, lockCopy, keptNow, keptBefore, holdOn, waitOutClosing, GATEWAY_HOLDER } from './copy.mjs';
45
+ import { HttpStorage } from '../core/storage.mjs';
46
+ import * as podInbox from '../pod/inbox.mjs';
43
47
  import { heldEntries } from './held-mail.mjs';
48
+ import { noteOpened } from './quiet.mjs';
44
49
  import { Push } from '../client/webpush.mjs';
45
50
  import webpush from 'web-push';
46
51
 
@@ -196,19 +201,65 @@ class GatewayPush extends Push {
196
201
  // ---- the account behind a token ----
197
202
 
198
203
  // Loaded copies, kept while this running copy lives: a check that finds the
199
- // copy unchanged costs one listing, not every document.
200
- const reading = new Map(); // handle -> PodStore
204
+ // copy unchanged costs one listing, not every document. With the hold off,
205
+ // the pod's state, read the same way.
206
+ const reading = new Map(); // handle (or pod:handle) -> PodStore
201
207
  const noAuthorities = { has: () => false, isLocalRequest: () => false, isLocal: () => false, wsAuthorities: () => [] };
202
208
 
203
- async function readingStore(ctx, handle, log) {
204
- let store = reading.get(handle);
205
- if (!store) {
206
- store = new PodStore({ storage: new CopyStorage(ctx.copyKv, handle, { holder: GATEWAY_HOLDER }), log });
209
+ // What an app reads: the copy, or with the hold off, the pod.
210
+ const stateOf = (ctx, handle, at) => (at.inCopy
211
+ ? new CopyStorage(ctx.copyKv, handle, { holder: GATEWAY_HOLDER })
212
+ : new HttpStorage(at.urls.state, at.podFetch));
213
+
214
+ async function readingStore(ctx, handle, at, log) {
215
+ const key = at.inCopy ? handle : `pod:${handle}`;
216
+ let held = reading.get(key);
217
+ if (!held) {
218
+ held = { store: new PodStore({ storage: stateOf(ctx, handle, at), log }) };
207
219
  if (reading.size > 200) reading.clear();
208
- reading.set(handle, store);
220
+ reading.set(key, held);
209
221
  }
210
- await store.load();
211
- return store;
222
+ // This request's own sign-in at the pod; the same place, so what is held stays.
223
+ else held.store.attach(stateOf(ctx, handle, at));
224
+ held.at = Date.now();
225
+ await held.store.load();
226
+ return held.store;
227
+ }
228
+
229
+ // What this running copy remembers of an account, a loaded copy or an answer,
230
+ // goes once nobody has asked for fifteen minutes, as the copy itself does
231
+ // (copy.mjs: the hold).
232
+ const MEMORY_MS = 15 * 60_000;
233
+ function forgetOld(now = Date.now()) {
234
+ for (const [k, v] of reading) if (now - v.at > MEMORY_MS) reading.delete(k);
235
+ for (const [k, v] of answered) if (now - v.at > MEMORY_MS) { answeredBytes -= v.body.length; answered.delete(k); }
236
+ }
237
+ const forget = (handle) => { reading.delete(handle); reading.delete(`pod:${handle}`); };
238
+
239
+ // An app's repeated check: while the copy's list of documents is as it was
240
+ // and no mail waits, the same question gets the same answer, without opening
241
+ // the copy. Ten minutes at most, for what changes with the clock alone (a
242
+ // poll closing). Kept per token, while this running copy lives.
243
+ const REPEAT_MS = 10 * 60_000;
244
+ const REPEAT_MAX_BYTES = 32 * 1024 * 1024;
245
+ const REPEATABLE = /^\/api\/v[12]\/(timelines\/[^/]+(\/[^/]+)?|notifications(\/unread_count)?|markers|conversations|follow_requests|announcements|accounts\/verify_credentials|lists|filters|preferences)$/u;
246
+ const answered = new Map(); // handle, token hash and the question -> { tag, at, status, headers, body }
247
+ let answeredBytes = 0;
248
+
249
+ async function repeatAnswer(ctx, handle, key, at) {
250
+ const had = answered.get(key);
251
+ if (!had || Date.now() - had.at > REPEAT_MS) return null;
252
+ const listing = await stateOf(ctx, handle, at).list('', { etag: had.tag }).catch(() => ({ notModified: false }));
253
+ if (!listing.notModified) return null;
254
+ if (ctx.listHeld && (await ctx.listHeld(handle)).length) return null;
255
+ return { status: had.status, headers: { ...had.headers }, body: had.body };
256
+ }
257
+
258
+ function rememberAnswer(key, tag, status, headers, body) {
259
+ if (body.length > REPEAT_MAX_BYTES / 64) return;
260
+ if (answeredBytes + body.length > REPEAT_MAX_BYTES) { answered.clear(); answeredBytes = 0; }
261
+ answeredBytes += body.length - (answered.get(key)?.body.length || 0);
262
+ answered.set(key, { tag, at: Date.now(), status, headers, body });
212
263
  }
213
264
 
214
265
  // What an app reading needs of an agent: the store, the account's addresses
@@ -246,19 +297,31 @@ async function notePushSubs(ctx, handle, store) {
246
297
  }
247
298
  export async function pushWanted(ctx, handle) { return Number((await ctx.mastoKv?.get(`push/${handle}`))?.text || 0) > 0; }
248
299
 
300
+ // With the hold off, mail is in the pod inbox, not held here: whether any is
301
+ // waiting, from one listing.
302
+ async function inboxWaiting(at) {
303
+ const items = await podInbox.list(at.remote, at.urls).catch(() => []);
304
+ return items.some((e) => !e.url.endsWith('.keep') && !e.url.endsWith('.receipt.json'));
305
+ }
306
+
249
307
  /**
250
308
  * Read what was held for an account into its copy, and push each notification
251
- * that makes to the owner's phones and browsers. `takeover`: act even when a
252
- * browser that went quiet still holds the lease (a push run); otherwise leave
253
- * the mail to whoever holds it (an app's check).
309
+ * that makes to the owner's phones and browsers. With the hold off, what waits
310
+ * in the pod inbox is drained instead. `takeover`: act even when a browser
311
+ * that went quiet still holds the lease (a push run); otherwise leave the mail
312
+ * to whoever holds it (an app's check).
254
313
  */
255
314
  async function readHeld(ctx, handle, rec, { log = console.log, takeover = false, waitMs = 1000 } = {}) {
256
- if (!(await ctx.listHeld(handle)).length) return 0;
315
+ const held = async () => !!(ctx.listHeld && (await ctx.listHeld(handle)).length);
316
+ if (holdOn(ctx) && !await held()) return 0;
257
317
  const unlock = await lockCopy(ctx.copyKv, handle, { waitMs });
258
318
  if (!unlock) return 0;
259
319
  try {
260
320
  const at = await reachAccount(ctx, handle, rec, { log });
261
- if (at.skipped || !at.inCopy) return 0;
321
+ if (at.skipped) return 0;
322
+ if (!(await settle(ctx, handle, rec, at, { log })).ok) return 0;
323
+ const drains = !at.inCopy;
324
+ if (drains && !await inboxWaiting(at) && !await held()) return 0;
262
325
  const { store, lease } = stateAndLease(ctx, handle, at, { log });
263
326
  if (!await lease.acquire() && !(takeover && await lease.takeover())) return 0;
264
327
  await store.load();
@@ -277,22 +340,23 @@ async function readHeld(ctx, handle, rec, { log = console.log, takeover = false,
277
340
  })().catch((e) => log(`push @${handle}: ${e.message}`)));
278
341
  };
279
342
  }
280
- const entries = await heldEntries(ctx, handle);
343
+ const entries = ctx.listHeld ? await heldEntries(ctx, handle) : [];
281
344
  const done = new Set(await agent.intake.takeHeld(entries));
282
345
  for (const e of entries) if (done.has(e.name)) for (const n of e.held) await ctx.dropHeld(handle, n);
346
+ if (drains) await agent.intake.drain();
283
347
  await Promise.all(pushes);
284
348
  await store.commit();
285
349
  await lease.release().catch(() => {});
286
350
  await ctx.noteNext?.(handle, nextDue(store)).catch(() => {});
287
- reading.delete(handle);
288
- if (done.size) log(`@${handle}: ${done.size} held deliveries read into the copy, ${pushes.length} pushed`);
289
- return done.size;
351
+ forget(handle);
352
+ if (done.size || drains) log(`@${handle}: ${drains ? 'the pod inbox drained' : `${done.size} held deliveries read into the copy`}, ${pushes.length} pushed`);
353
+ return done.size || (drains ? 1 : 0);
290
354
  } finally { await unlock(); }
291
355
  }
292
356
 
293
357
  /** For an app checking in: at most every thirty seconds, never over a browser. */
294
358
  export async function readHeldForApp(ctx, handle, rec, { log = console.log } = {}) {
295
- if (!ctx.listHeld) return 0;
359
+ if (!ctx.listHeld && holdOn(ctx)) return 0;
296
360
  const key = `${handle}/mail-read-at`;
297
361
  const last = Number((await ctx.copyKv.get(key))?.text || 0);
298
362
  if (Date.now() - last < MAIL_EVERY_MS) return 0;
@@ -307,7 +371,7 @@ export async function readHeldForApp(ctx, handle, rec, { log = console.log } = {
307
371
  * burst of likes is a run or two, not one each.
308
372
  */
309
373
  export async function pushHeld(ctx, handle, rec, { log = console.log } = {}) {
310
- if (!ctx.listHeld || !await pushWanted(ctx, handle)) return 0;
374
+ if ((!ctx.listHeld && holdOn(ctx)) || !await pushWanted(ctx, handle)) return 0;
311
375
  const kv = ctx.mastoKv;
312
376
  const wanted = async () => (await kv.get(`pushwant/${handle}`))?.text || null;
313
377
  let n = 0;
@@ -326,21 +390,44 @@ export async function pushHeld(ctx, handle, rec, { log = console.log } = {}) {
326
390
  return n;
327
391
  }
328
392
 
329
- // A request from an app, answered by the account's facade over its copy.
330
- async function answerForAccount(request, url, ctx, tok, rec, log) {
393
+ // A request from an app, answered by the account's facade over its copy, or
394
+ // with the hold off, over the pod.
395
+ async function answerForAccount(request, url, ctx, tok, rec, log, tokenHash) {
331
396
  const handle = tok.handle;
397
+ forgetOld();
398
+ const repeatKey = request.method === 'GET' && REPEATABLE.test(url.pathname) ? `${handle}\n${tokenHash}\n${url.pathname}${url.search}` : null;
399
+ // The copy's listing as it was: the same answer, without opening the copy.
400
+ // A copy written to the pod and deleted since lists differently.
401
+ if (repeatKey && holdOn(ctx)) {
402
+ const again = await repeatAnswer(ctx, handle, repeatKey, { inCopy: true });
403
+ if (again) return again;
404
+ }
405
+ // A copy the round is writing to the pod and deleting is waited out; it is
406
+ // made again below.
407
+ await waitOutClosing(ctx.copyKv, handle);
332
408
  const at = await reachAccount(ctx, handle, rec, { log });
333
409
  if (at.skipped) return json(503, { error: `this account cannot be reached now: ${at.skipped}` });
334
- if (!at.inCopy) {
410
+ const token = { token: 'gateway', scope: tok.scope };
411
+ const writes = request.method !== 'GET' && request.method !== 'HEAD';
412
+ if (!writes && !at.inCopy && holdOn(ctx)) {
335
413
  const made = await ensureCopy(ctx, handle, rec, log);
336
414
  if (!made.ok) return json(made.status === 409 ? 503 : made.status, { error: made.why });
337
415
  at.inCopy = true;
338
416
  }
339
- const token = { token: 'gateway', scope: tok.scope };
340
- const writes = request.method !== 'GET' && request.method !== 'HEAD';
417
+ // New mail read in beside this answer, for the app's next check.
418
+ if (!writes && /^\/api\/v[12]\/(timelines\/home|notifications)$/u.test(url.pathname)) {
419
+ ctx.waitUntil?.(readHeldForApp(ctx, handle, rec, { log }).catch((e) => log(`app @${handle}: held mail: ${e.message}`)));
420
+ }
421
+ // With the hold off, the pod's listing, after the inbox has been looked at.
422
+ if (repeatKey && !holdOn(ctx)) {
423
+ const again = await repeatAnswer(ctx, handle, repeatKey, at);
424
+ if (again) return again;
425
+ }
341
426
  const b = await bridge(request, url);
342
427
  if (!writes) {
343
- const store = await readingStore(ctx, handle, log);
428
+ const store = await readingStore(ctx, handle, at, log);
429
+ // The copy as this answer reads it: a later check finding the same list repeats it.
430
+ const tag = store.etags.get('');
344
431
  let actingP = null;
345
432
  const acting = () => (actingP ||= (async () => {
346
433
  const s = stateAndLease(ctx, handle, at, { log });
@@ -353,17 +440,23 @@ async function answerForAccount(request, url, ctx, tok, rec, log) {
353
440
  if (!agent) return json(503, { error: 'this account\'s copy holds no account' });
354
441
  const api = facade(agent, token, log, await vapidKeys(ctx.mastoKv), pushContact(ctx));
355
442
  const handled = await api.handle(b.req, b.res, url.pathname, url);
356
- // New mail read in after this answer, for the app's next check.
357
- if (/^\/api\/v[12]\/(timelines\/home|notifications)$/u.test(url.pathname)) {
358
- ctx.waitUntil?.(readHeldForApp(ctx, handle, rec, { log }).catch((e) => log(`app @${handle}: held mail: ${e.message}`)));
359
- }
360
- return handled ? b.response() : new Response(JSON.stringify({ error: 'not found' }), { status: 404 });
443
+ if (!handled) return new Response(JSON.stringify({ error: 'not found' }), { status: 404 });
444
+ const res = b.response();
445
+ // An answer that had to act (a fetch from another server) is not repeated.
446
+ if (!repeatKey || actingP || res.status !== 200 || !tag) return res;
447
+ const headers = Object.fromEntries(res.headers);
448
+ const body = await res.text();
449
+ rememberAnswer(repeatKey, tag, res.status, headers, body);
450
+ return { status: res.status, headers, body };
361
451
  }
362
452
  // Acting: this app outranks a browser sitting open, as one device does another.
363
453
  const unlock = await lockCopy(ctx.copyKv, handle);
364
454
  if (!unlock) return json(503, { error: 'this account is busy; try again' });
365
455
  let lease = null;
366
456
  try {
457
+ // Decided under the lock, so the round cannot delete the copy meanwhile.
458
+ const settled = await settle(ctx, handle, rec, at, { log });
459
+ if (!settled.ok) return json(settled.status === 409 ? 503 : settled.status || 503, { error: settled.why });
367
460
  const s = stateAndLease(ctx, handle, at, { log });
368
461
  if (!await s.lease.acquire() && !await s.lease.takeover()) return json(503, { error: 'could not take this account over; try again' });
369
462
  lease = s.lease;
@@ -377,7 +470,7 @@ async function answerForAccount(request, url, ctx, tok, rec, log) {
377
470
  if (url.pathname === '/api/v1/push/subscription') await notePushSubs(ctx, handle, store);
378
471
  agent.publisher.stopPolls?.();
379
472
  await ctx.noteNext?.(handle, nextDue(store)).catch(() => {});
380
- reading.delete(handle);
473
+ forget(handle);
381
474
  return handled ? b.response() : new Response(JSON.stringify({ error: 'not found' }), { status: 404 });
382
475
  } finally {
383
476
  // Done acting, however it went: the lease goes back, so a browser sitting
@@ -478,14 +571,17 @@ async function route(request, pathname, ctx, deps, log) {
478
571
  if (keptBefore(ctx, rec)) return json(503, { error: MOVING });
479
572
  if (!usable(ctx, rec)) return json(409, { error: notReady(rec) });
480
573
  if (rec.webId !== webid) return json(403, { error: `you signed in as ${webid}, and this account belongs to ${rec.webId}` });
574
+ // Signing an app in is the owner being here, as signing in to FediPod is.
575
+ await noteOpened(ctx, handle, rec, { always: true }).catch((e) => log(`@${handle}: stamp not written: ${e?.message || e}`));
481
576
  const app = await appOf(kv, body.client_id);
482
577
  if (!app) return json(400, { error: 'this app has not registered here' });
483
578
  const redirect = body.redirect_uri || 'urn:ietf:wg:oauth:2.0:oob';
484
579
  if (redirect !== 'urn:ietf:wg:oauth:2.0:oob' && !app.redirectUris.includes(redirect)) {
485
580
  return json(400, { error: 'this app asked to be sent somewhere it did not register' });
486
581
  }
487
- // The owner, proved: not an app checking in, so not made to wait.
488
- const made = await ensureCopy(ctx, handle, rec, log, { owner: true });
582
+ // The owner, proved: not an app checking in, so not made to wait. With
583
+ // the hold off there is no copy to make; the app reads the pod.
584
+ const made = holdOn(ctx) ? await ensureCopy(ctx, handle, rec, log, { owner: true }) : { ok: true };
489
585
  if (!made.ok) return json(made.status === 409 ? 503 : made.status, { error: made.why });
490
586
  const code = rand(24);
491
587
  await putJson(kv, `code/${sha(code)}`, {
@@ -537,7 +633,8 @@ async function route(request, pathname, ctx, deps, log) {
537
633
 
538
634
  // Everything else needs a token.
539
635
  const bearer = /^Bearer (.+)$/u.exec(request.headers.get('authorization') || '')?.[1];
540
- const tok = bearer ? await getJson(kv, `token/${sha(bearer)}`) : null;
636
+ const tokenHash = bearer ? sha(bearer) : null;
637
+ const tok = tokenHash ? await getJson(kv, `token/${tokenHash}`) : null;
541
638
  if (!tok || Date.now() - tok.at > TOKEN_TTL_MS) {
542
639
  // A page of this site asking with a token the gateway never gave out is
543
640
  // FediPod's own client in a browser whose worker is not answering yet:
@@ -555,5 +652,8 @@ async function route(request, pathname, ctx, deps, log) {
555
652
  // by somebody else is not theirs.
556
653
  if (rec && rec.webId === tok.webId && keptBefore(ctx, rec)) return json(503, { error: MOVING });
557
654
  if (!usable(ctx, rec) || rec.webId !== tok.webId) return json(401, { error: 'this account is not kept running here any more' });
558
- return answerForAccount(request, url, ctx, tok, rec, log);
655
+ // Using an app is being here: no pause, no closing, for want of opening
656
+ // FediPod. Hourly at most; see noteOpened.
657
+ const here = await noteOpened(ctx, tok.handle, rec).catch((e) => { log(`@${tok.handle}: stamp not written: ${e?.message || e}`); return rec; });
658
+ return answerForAccount(request, url, ctx, tok, here, log, tokenHash);
559
659
  }
@@ -0,0 +1,98 @@
1
+ // private-read.mjs — a followers-only or direct post, read at its address by
2
+ // a server it was sent to (ActivityPub §3.2). The read is signed the way a
3
+ // delivery is; the signer is resolved; and the post is served only to an
4
+ // actor in its audience: a follower for a post sent to followers, a named
5
+ // recipient for a direct one. That is the rule Mastodon applies to its own
6
+ // posts (StatusPolicy#show?). Anyone else learns nothing — 404, as for a
7
+ // document that is not there, which §3.2 allows for a private target.
8
+ //
9
+ // At fedipod.net this works for an account it keeps: the post is read from
10
+ // the pod with the keeper's credential, and the followers list from the
11
+ // account's copy, or from the pod when there is no copy just then (the hold,
12
+ // copy.mjs). Nothing read is kept. An account it does not keep gives it
13
+ // nothing to read with,
14
+ // so that read is sent on to the pod, as every private read was.
15
+
16
+ import { verifyHttpSignature, makeSafeLoader } from './httpsig.mjs';
17
+ import { asSigned } from './gateway-core.mjs';
18
+ import { senderKeys } from './caches.mjs';
19
+ import { keptNow } from './copy.mjs';
20
+
21
+ const AP_CT = 'application/activity+json';
22
+ const PUBLIC_NAMES = new Set(['https://www.w3.org/ns/activitystreams#Public', 'as:Public', 'Public']);
23
+ const list = (v) => (v == null ? [] : Array.isArray(v) ? v : [v])
24
+ .map((x) => (typeof x === 'string' ? x : x?.id)).filter((x) => typeof x === 'string');
25
+
26
+ /** Who a document was sent to: the actors named, and whether followers were. */
27
+ export function audienceOf(doc, followersUrls = []) {
28
+ const o = doc?.type === 'Create' && doc.object && typeof doc.object === 'object' ? doc.object : doc;
29
+ const named = new Set(['to', 'cc', 'audience'].flatMap((k) => [...list(doc?.[k]), ...list(o?.[k])]));
30
+ return {
31
+ named,
32
+ isPublic: [...named].some((a) => PUBLIC_NAMES.has(a)),
33
+ toFollowers: followersUrls.some((f) => named.has(f)),
34
+ };
35
+ }
36
+
37
+ /** Whether `actor` may read `doc`: named in its audience, or a follower when it went to followers. */
38
+ export function mayRead(doc, actor, { followersUrls = [], followers = [] } = {}) {
39
+ if (typeof actor !== 'string' || !actor) return false;
40
+ const a = audienceOf(doc, followersUrls);
41
+ if (a.isPublic || a.named.has(actor)) return true;
42
+ return a.toFollowers && followers.includes(actor);
43
+ }
44
+
45
+ /**
46
+ * The actor behind a signed read, or null with why. `origin` is the door's
47
+ * own (see asSigned in gateway-core.mjs); a GET has no body to digest.
48
+ */
49
+ export async function signedReader(request, { fetchImpl = fetch, origin = null } = {}) {
50
+ if (!request.headers.get('signature')) return { actor: null, reason: 'no-signature' };
51
+ const v = await verifyHttpSignature(asSigned(request, new Uint8Array(0), origin), {
52
+ documentLoader: makeSafeLoader({ fetchImpl }), keyCache: senderKeys,
53
+ });
54
+ return v.verified && typeof v.actor === 'string' ? { actor: v.actor, reason: null } : { actor: null, reason: v.reason };
55
+ }
56
+
57
+ // Refused reads are told nothing, and never held at the edge.
58
+ const nothing = (open) => ({ status: 404, headers: { ...open, 'cache-control': 'no-store', vary: 'Signature' }, body: '' });
59
+
60
+ /**
61
+ * A read under `ap/private/` at the front. Unsigned: sent on to the pod, which
62
+ * decides (the owner reads there with their own credential). Signed: answered
63
+ * here for a kept account, to a reader the post was sent to.
64
+ */
65
+ export async function answerPrivateRead(ctx, rec, { request, handle, podTarget, base, open, swap }) {
66
+ if (!request.headers.get('signature')) {
67
+ return { status: 303, headers: { ...open, location: podTarget, 'cache-control': 'no-store' }, body: '' };
68
+ }
69
+ const say = (what) => console.log(`private read @${handle}: ${what}`);
70
+ if (!keptNow(ctx, rec) || typeof ctx.keeperFetch !== 'function' || !ctx.copyKv) { say('refused — account not kept here'); return nothing(open); }
71
+ const { actor, reason } = await signedReader(request, { fetchImpl: ctx.fetchImpl || fetch, origin: ctx.frontOrigin || null });
72
+ if (!actor) { say(`refused — ${reason}`); return nothing(open); }
73
+ const podFetch = await ctx.keeperFetch();
74
+ if (!podFetch) { say('refused — no keeper credential'); return nothing(open); }
75
+ let text = null;
76
+ try {
77
+ const res = await podFetch(podTarget, { headers: { accept: AP_CT } });
78
+ if (res.status === 200) text = await res.text();
79
+ } catch { /* unreachable: nothing to serve */ }
80
+ let doc = null;
81
+ try { doc = text ? JSON.parse(text) : null; } catch { doc = null; }
82
+ if (!doc) { say(`refused — the pod did not give it (${actor})`); return nothing(open); }
83
+ // Who follows: from the account's copy while there is one, else from the pod.
84
+ let followers = [];
85
+ try {
86
+ let contacts = (await ctx.copyKv.get(`${handle}/d/contacts.json`))?.text || null;
87
+ if (!contacts) {
88
+ const res = await podFetch(`${rec.podHome.replace(/\/?$/u, '/')}ap-state/contacts.json`, { headers: { accept: 'application/json' } });
89
+ if (res.status === 200) contacts = await res.text();
90
+ }
91
+ followers = (JSON.parse(contacts || '{}').followers || []).map((f) => f?.actor).filter(Boolean);
92
+ } catch { followers = []; }
93
+ const followersUrls = [rec.podHome + 'ap/followers', base + 'ap/followers'];
94
+ if (!mayRead(doc, actor, { followersUrls, followers })) { say(`refused — ${actor} is not in its audience`); return nothing(open); }
95
+ say(`served to ${actor}`);
96
+ return { status: 200, headers: { ...open, 'content-type': AP_CT, 'cache-control': 'no-store', vary: 'Signature' },
97
+ body: request.method === 'HEAD' ? '' : swap(text, rec.podHome, base) };
98
+ }
@@ -4,7 +4,7 @@
4
4
  // public read, stamps noteOpened from the relay, counts noteReceived after
5
5
  // a forwarded delivery, and hands /api/open, /api/pause and /api/close here.
6
6
 
7
- import { keptNow } from './copy.mjs';
7
+ import { keptNow, holdOn } from './copy.mjs';
8
8
 
9
9
  // ---- accounts that go quiet -------------------------------------------------
10
10
  //
@@ -144,9 +144,10 @@ export async function routeQuietApi(request, pathname, ctx, deps) {
144
144
  if (before.closed) return j(410, { error: 'this address is closed', closedAt: before.closedAt, closedBy: before.closedBy });
145
145
  rec = await noteOpened(ctx, handle, rec, { always: true });
146
146
  // Whom the app would name if its owner lets the gateway act for them while
147
- // it is closed, and whether they have (held-mail.mjs, keeper.mjs).
147
+ // it is closed, and whether they have (held-mail.mjs, keeper.mjs); and
148
+ // whether the gateway holds a copy of the account at all (copy.mjs).
148
149
  return j(200, { ok: true, handle, ...await accountState(ctx, handle, rec),
149
- keeper: ctx.keeperWebId || null, kept: keptNow(ctx, rec), keptBy: rec.keeper?.webId || null });
150
+ keeper: ctx.keeperWebId || null, kept: keptNow(ctx, rec), keptBy: rec.keeper?.webId || null, hold: holdOn(ctx) });
150
151
  }
151
152
 
152
153
  // The owner pausing their own account, or lifting the pause they set.