fedipod 1.45.0 → 1.48.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 (54) hide show
  1. package/README.md +11 -16
  2. package/architecture.md +6 -3
  3. package/gateway.md +51 -23
  4. package/gui.md +3 -2
  5. package/lib/client/c2s.mjs +2 -1
  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 +4 -0
  11. package/lib/core/publisher/notes.mjs +4 -1
  12. package/lib/core/social.mjs +2 -0
  13. package/lib/core/storage.mjs +54 -5
  14. package/lib/core/wire.mjs +16 -5
  15. package/lib/gateway/account-agent.mjs +20 -4
  16. package/lib/gateway/copy.mjs +95 -5
  17. package/lib/gateway/front-core.mjs +4 -4
  18. package/lib/gateway/held-mail.mjs +11 -4
  19. package/lib/gateway/keeper.mjs +9 -6
  20. package/lib/gateway/masto-gateway.mjs +140 -40
  21. package/lib/gateway/private-read.mjs +10 -3
  22. package/lib/gateway/quiet.mjs +4 -3
  23. package/lib/gateway/state-api.mjs +73 -18
  24. package/lib/pod/notes.mjs +25 -0
  25. package/lib/server/embed.mjs +1 -1
  26. package/lib/session/README.md +2 -135
  27. package/lib/surface/routes/owner.mjs +2 -2
  28. package/lib/surface/surface.mjs +4 -3
  29. package/package.json +5 -3
  30. package/scripts/check-netlify-bundles.mjs +0 -4
  31. package/scripts/check-pod-calls.mjs +5 -108
  32. package/scripts/check-sizes.mjs +6 -3
  33. package/scripts/dependents.mjs +76 -0
  34. package/scripts/stage-site.mjs +10 -8
  35. package/scripts/start.mjs +2 -2
  36. package/scripts/test-dependents.mjs +37 -0
  37. package/web/admin/gateway.js +8 -24
  38. package/web/admin/index.html +0 -16
  39. package/web/admin/record.js +1 -2
  40. package/web/admin/upkeep.js +1 -5
  41. package/web/app/admin-facade.mjs +1 -1
  42. package/web/app/agent.mjs +27 -10
  43. package/web/app/copy-mode.mjs +98 -13
  44. package/web/app/dist/boot.js +68 -57
  45. package/web/app/dist/boot.js.map +4 -4
  46. package/web/app/dist/sw.js +583 -283
  47. package/web/app/dist/sw.js.map +4 -4
  48. package/web/app/forum-support.mjs +2 -2
  49. package/web/app/oidc-session.mjs +2 -2
  50. package/lib/session/demo.html +0 -94
  51. package/lib/session/fedi-account.mjs +0 -417
  52. package/lib/session/fedi-login.mjs +0 -123
  53. package/lib/session/oidc-session.mjs +0 -233
  54. package/lib/session/package.json +0 -49
@@ -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
  }
@@ -8,7 +8,9 @@
8
8
  //
9
9
  // At fedipod.net this works for an account it keeps: the post is read from
10
10
  // the pod with the keeper's credential, and the followers list from the
11
- // account's copy. An account it does not keep gives it nothing to read with,
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,
12
14
  // so that read is sent on to the pod, as every private read was.
13
15
 
14
16
  import { verifyHttpSignature, makeSafeLoader } from './httpsig.mjs';
@@ -78,10 +80,15 @@ export async function answerPrivateRead(ctx, rec, { request, handle, podTarget,
78
80
  let doc = null;
79
81
  try { doc = text ? JSON.parse(text) : null; } catch { doc = null; }
80
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.
81
84
  let followers = [];
82
85
  try {
83
- const got = await ctx.copyKv.get(`${handle}/contacts.json`);
84
- followers = (JSON.parse(got?.text || '{}').followers || []).map((f) => f?.actor).filter(Boolean);
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);
85
92
  } catch { followers = []; }
86
93
  const followersUrls = [rec.podHome + 'ap/followers', base + 'ap/followers'];
87
94
  if (!mayRead(doc, actor, { followersUrls, followers })) { say(`refused — ${actor} is not in its audience`); return nothing(open); }
@@ -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.
@@ -13,13 +13,21 @@
13
13
  // before the owner stops the gateway keeping
14
14
  // the account running
15
15
  //
16
+ // The hold (copy.mjs): the round writes a person's copy to the pod and deletes
17
+ // it every fifteen minutes. A document asked for after that makes the copy
18
+ // again from the pod, and one asked for while it goes is answered 503, to be
19
+ // asked again in a moment. The copy's lease outlives it. With the hold off,
20
+ // no copy is made: /open answers 501, after writing to the pod and deleting
21
+ // any copy left from before, and the browser works on the pod.
22
+ //
16
23
  // The token is the gateway's own, signed with a secret only it knows, and
17
24
  // names the account and the WebID that asked; it lasts a day. The signing key
18
25
  // and the passwords of accounts elsewhere are refused here: the copy does not
19
26
  // hold them, and the browser reads them from the pod.
20
27
  import crypto from 'node:crypto';
21
28
  import { HttpStorage } from '../core/storage.mjs';
22
- import { CopyStorage, copyMeta, fillCopy, dropCopy, forgetCopy, holds, lockCopy, kvDocFetch, podOnly, safeName, safeHandle, keptBefore } from './copy.mjs';
29
+ import { CopyStorage, copyMeta, fillCopy, dropCopy, forgetCopy, holds, lockCopy, kvDocFetch, podOnly, safeName, safeHandle, keptBefore, keptNow,
30
+ holdOn, holdOver, closing } from './copy.mjs';
23
31
 
24
32
  const TOKEN_MS = 24 * 3600_000;
25
33
  const b64 = (s) => Buffer.from(s).toString('base64url');
@@ -79,6 +87,14 @@ const noteFailure = (kv, handle, why = null) => kv.set(`${handle}/fill-failed-at
79
87
  * credential has changed since. Returns { ok } or { ok: false, status, why }.
80
88
  */
81
89
  export async function ensureCopy(ctx, handle, rec, log = console.log, { owner = false } = {}) {
90
+ if (await copyMeta(ctx.copyKv, handle)) return { ok: true };
91
+ const unlock = await lockCopy(ctx.copyKv, handle);
92
+ if (!unlock) return { ok: false, status: 503, why: 'the gateway is busy with this account; try again' };
93
+ try { return await makeCopy(ctx, handle, rec, log, { owner }); } finally { await unlock(); }
94
+ }
95
+
96
+ /** ensureCopy, for a caller already holding lockCopy for the account. */
97
+ export async function makeCopy(ctx, handle, rec, log = console.log, { owner = false } = {}) {
82
98
  if (await copyMeta(ctx.copyKv, handle)) return { ok: true };
83
99
  // The owner too: it is the gateway's credential that has to change.
84
100
  const refused = await loginRefused(ctx);
@@ -90,26 +106,41 @@ export async function ensureCopy(ctx, handle, rec, log = console.log, { owner =
90
106
  }
91
107
  const podFetch = await ctx.keeperFetch();
92
108
  if (!podFetch) return { ok: false, status: 501, why: 'this gateway cannot reach pods for accounts' };
93
- const unlock = await lockCopy(ctx.copyKv, handle);
94
- if (!unlock) return { ok: false, status: 503, why: 'the gateway is busy with this account; try again' };
109
+ // Marked failed before it starts and cleared when it is done, so an attempt
110
+ // cut off part way (a request's time running out) counts as failed too.
111
+ await noteFailure(ctx.copyKv, handle);
112
+ const stateUrl = stateUrlOf(rec);
113
+ const made = await fillCopy(ctx.copyKv, handle, { pod: new HttpStorage(stateUrl, podFetch), podFetch, stateUrl, webId: rec.webId, log })
114
+ .catch((e) => ({ ok: false, why: e.message }));
115
+ if (made.ok) { await ctx.copyKv.delete(`${handle}/fill-failed-at`).catch(() => {}); return { ok: true }; }
116
+ await noteFailure(ctx.copyKv, handle, made.why);
117
+ log(`copy @${handle}: not made: ${made.why}`);
118
+ if (refusedSignIn(made.why)) {
119
+ await ctx.copyKv.set(LOGIN_REFUSED, JSON.stringify({ at: Date.now(), mark: ctx.keeperMark || '', why: made.why })).catch(() => {});
120
+ }
121
+ return { ok: false, status: /active on another device/.test(made.why) ? 409 : 502, why: made.why };
122
+ }
123
+
124
+ /**
125
+ * The end of the hold for one account now, as the round does it: the copy
126
+ * written to the pod and deleted. Returns { ok } or { ok: false, why }.
127
+ */
128
+ export async function endHold(ctx, handle, rec, log = console.log, { waitMs = 8_000 } = {}) {
129
+ if (!await copyMeta(ctx.copyKv, handle)) return { ok: true };
130
+ const podFetch = await ctx.keeperFetch();
131
+ if (!podFetch) return { ok: false, why: 'this gateway cannot reach the pod now' };
132
+ const unlock = await lockCopy(ctx.copyKv, handle, { waitMs });
133
+ if (!unlock) return { ok: false, why: 'the gateway is busy with this account; try again' };
95
134
  try {
96
- if (await copyMeta(ctx.copyKv, handle)) return { ok: true };
97
- // Marked failed before it starts and cleared when it is done, so an attempt
98
- // cut off part way (a request's time running out) counts as failed too.
99
- await noteFailure(ctx.copyKv, handle);
100
135
  const stateUrl = stateUrlOf(rec);
101
- const made = await fillCopy(ctx.copyKv, handle, { pod: new HttpStorage(stateUrl, podFetch), podFetch, stateUrl, webId: rec.webId, log })
102
- .catch((e) => ({ ok: false, why: e.message }));
103
- if (made.ok) { await ctx.copyKv.delete(`${handle}/fill-failed-at`).catch(() => {}); return { ok: true }; }
104
- await noteFailure(ctx.copyKv, handle, made.why);
105
- log(`copy @${handle}: not made: ${made.why}`);
106
- if (refusedSignIn(made.why)) {
107
- await ctx.copyKv.set(LOGIN_REFUSED, JSON.stringify({ at: Date.now(), mark: ctx.keeperMark || '', why: made.why })).catch(() => {});
108
- }
109
- return { ok: false, status: /active on another device/.test(made.why) ? 409 : 502, why: made.why };
136
+ return await holdOver(ctx.copyKv, handle, { pod: new HttpStorage(stateUrl, podFetch), podFetch, stateUrl, log });
110
137
  } finally { await unlock(); }
111
138
  }
112
139
 
140
+ // Whether the copy is made again when it is asked for: the hold on, and the
141
+ // account still kept here under this identity.
142
+ const remakes = (ctx, rec) => holdOn(ctx) && keptNow(ctx, rec) && !keptBefore(ctx, rec) && !rec.movedTo && !rec.closedAt;
143
+
113
144
  /** Write the copy to the pod and give it up. Returns { ok } or { ok: false, why }. */
114
145
  export async function leaveCopy(ctx, handle, rec, log = console.log) {
115
146
  if (!await copyMeta(ctx.copyKv, handle)) return { ok: true };
@@ -155,6 +186,13 @@ export async function routeStateApi(request, pathname, ctx, deps) {
155
186
  // owner's rules name it.
156
187
  const before = keptBefore(ctx, rec);
157
188
  if (before && !await copyMeta(ctx.copyKv, handle)) return j(409, { error: 'kept under the gateway\'s former identity', moving: true });
189
+ // The hold off: no copy. One left from before goes to the pod first, so
190
+ // the browser works on a pod that has everything.
191
+ if (!before && !holdOn(ctx)) {
192
+ const ended = await endHold(ctx, handle, rec).catch((e) => ({ ok: false, why: e.message }));
193
+ if (!ended.ok) return j(503, { error: `the account's copy could not be written to the pod: ${ended.why}` });
194
+ return j(501, { error: 'this gateway keeps no copies of accounts', hold: false });
195
+ }
158
196
  const made = before ? { ok: true } : await ensureCopy(ctx, handle, rec, console.log, { owner: true });
159
197
  if (!made.ok) return j(made.status, { error: made.why, busy: made.status === 409 });
160
198
  const origin = new URL(request.url).origin;
@@ -177,15 +215,28 @@ export async function routeStateApi(request, pathname, ctx, deps) {
177
215
  // by somebody else is not theirs any more.
178
216
  const owned = await ctx.lookup(handle);
179
217
  if (!owned || owned.webId !== who.webId) return j(401, { error: 'this token is not for this account' });
180
- if (!await copyMeta(ctx.copyKv, handle)) return j(404, { error: 'the gateway keeps no copy of this account now', gone: true });
181
218
  const kv = ctx.copyKv;
182
219
  const noStore = { 'cache-control': 'no-store' };
220
+ const gone = () => j(404, { error: 'the gateway keeps no copy of this account now', gone: true });
183
221
 
222
+ // No copy, or none now, is nothing to give up.
184
223
  if (name === 'leave') {
185
224
  if (request.method !== 'POST') return j(405, { error: 'POST' });
186
225
  const left = await leaveCopy(ctx, handle, owned).catch((e) => ({ ok: false, why: e.message }));
187
226
  return left.ok ? j(200, { ok: true }) : j(502, { error: `the copy could not be written to the pod: ${left.why}` });
188
227
  }
228
+ // The copy's lease outlives each copy the hold makes.
229
+ if (name === 'lease.json' && !await copyMeta(kv, handle) && !remakes(ctx, owned)) return gone();
230
+ // Being written to the pod and deleted: asked again in a moment, it is made anew.
231
+ const comeAgain = () => j(503, { error: 'the account\'s copy is being written to the pod; try again in a moment' });
232
+ if (name !== 'lease.json') {
233
+ if (await closing(kv, handle)) return comeAgain();
234
+ if (!await copyMeta(kv, handle)) {
235
+ if (!remakes(ctx, owned)) return gone();
236
+ const made = await ensureCopy(ctx, handle, owned, console.log, { owner: true });
237
+ if (!made.ok) return j(made.status === 409 ? 503 : made.status, { error: made.why });
238
+ }
239
+ }
189
240
  // The owner's browser has written the copy to the pod itself (a copy kept
190
241
  // under the gateway's former identity, which cannot write it back now).
191
242
  // Only the lease's holder may say so, and only for such a copy.
@@ -229,11 +280,15 @@ export async function routeStateApi(request, pathname, ctx, deps) {
229
280
  const w = await copy.write(name, await request.text());
230
281
  if (w.lost) return j(409, { error: w.why });
231
282
  if (!w.ok) return j(503, { error: w.why });
283
+ // Landed as the round began to write the copy to the pod: perhaps after
284
+ // it read this document. Sent again, it lands in the copy made next.
285
+ if (await closing(kv, handle)) return comeAgain();
232
286
  return { status: 204, headers: { ...noStore, etag: w.etag }, body: null };
233
287
  }
234
288
  if (request.method === 'DELETE') {
235
289
  if (!holder) return j(400, { error: 'x-fedipod-holder is required' });
236
- return (await copy.remove(name)) ? { status: 204, headers: noStore, body: null } : j(409, { error: 'another agent holds this account now' });
290
+ if (!await copy.remove(name)) return j(409, { error: 'another agent holds this account now' });
291
+ return (await closing(kv, handle)) ? comeAgain() : { status: 204, headers: noStore, body: null };
237
292
  }
238
293
  return j(405, { error: 'GET, PUT or DELETE' });
239
294
  }
package/lib/pod/notes.mjs CHANGED
@@ -44,6 +44,31 @@ export const writeReplies = (pod, repliesId, doc) => pod.putJson(repliesId, doc)
44
44
  /** One published note, as the pod currently serves it. */
45
45
  export const read = (pod, noteId) => pod.getJson(noteId);
46
46
 
47
+ /**
48
+ * A note as the pod serves it, with the version the pod gave it.
49
+ *
50
+ * For a write that must not undo somebody else's: an edit or a deletion that
51
+ * lands between this read and the write below wins, and the writer tries
52
+ * again later. A pod that names no version gets an unconditional write.
53
+ */
54
+ export async function readVersioned(pod, noteId) {
55
+ if (typeof pod.fetch !== 'function') return { doc: await pod.getJson(noteId), version: null };
56
+ const res = await pod.fetch(noteId, { headers: { accept: 'application/activity+json' } });
57
+ if (res.status === 404 || res.status === 410) return { doc: null, version: null };
58
+ if (res.status >= 400) throw new Error(`GET ${noteId} → ${res.status}`);
59
+ return { doc: await res.json().catch(() => null), version: res.headers?.get?.('etag') || null };
60
+ }
61
+
62
+ /** Write the note only over the version read. False when it changed meanwhile. */
63
+ export async function writeIfUnchanged(pod, noteId, doc, version) {
64
+ if (!version || typeof pod.fetch !== 'function') { await pod.putJson(noteId, doc); return true; }
65
+ const res = await pod.fetch(noteId, { method: 'PUT',
66
+ headers: { 'content-type': 'application/activity+json', 'if-match': version }, body: JSON.stringify(doc) });
67
+ if (res.status === 412) return false;
68
+ if (res.status >= 400) throw new Error(`PUT ${noteId} → ${res.status}`);
69
+ return true;
70
+ }
71
+
47
72
  /**
48
73
  * Replace a note with a Tombstone, and keep it readable.
49
74
  *
@@ -396,7 +396,7 @@ export async function startEmbeddedAgent({
396
396
  basePath: uiPath,
397
397
  mount,
398
398
  publicOrigin: base,
399
- scheme: new URL(base).protocol,
399
+ scheme: new URL(base).protocol.slice(0, -1),
400
400
  });
401
401
 
402
402
  // A delivery landing in the inbox — through the door, or from any other