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
package/web/app/boot.mjs CHANGED
@@ -14,13 +14,15 @@
14
14
  // fedipodMoveIn(answers) brings the address here.
15
15
  // Returning: fedipodSignin({ address }) — redirect to the pod's login.
16
16
  // On every load: fedipodOnLoad() — finish a redirect, or restore, then boot.
17
- import { signUp, moveIn, readAccount, handleProblem, AP_ROOT } from './signup.mjs';
17
+ import { signUp, moveIn, readAccount, handleProblem } from './signup.mjs';
18
+ import { podRootPath, chosenRoot } from '../../lib/core/place.mjs';
18
19
  import * as podActor from '../../lib/pod/actor.mjs';
19
20
  import * as podState from '../../lib/pod/state.mjs';
20
21
  import { podBaseOfWebId } from '../../lib/pod/urls.mjs';
21
22
  import { resourceExists } from '../../lib/pod/root.mjs';
22
23
  import { BrowserRemotePod } from './pod-remote.mjs';
23
24
  import { beginLogin, completeLogin, getSession, signOut } from './oidc-session.mjs';
25
+ import { deleteWarm } from './warm-start.mjs';
24
26
  import { generateKeys, unwrapKeys, isKeyEnvelope } from './keystore.mjs';
25
27
  import { cacheOpenedKeys } from './keys-browser.mjs';
26
28
 
@@ -76,16 +78,15 @@ async function bootWorker({ reset = false } = {}) {
76
78
  async function readAccountState() {
77
79
  const session = await getSession();
78
80
  if (!session) throw new Error('Sign in first.');
79
- const podFromWebId = podBaseOfWebId(session.webId); // a suffix-based host, or its own host
80
- const state = `${podFromWebId}${AP_ROOT}ap-state/`;
81
+ // Wherever its owner put it: the type index says, or it is an older account
82
+ // at `fedipod/` (signup.mjs readAccount).
83
+ const here = await readAccount(session);
84
+ if (!here) throw new Error(`could not find a FediPod account on ${podBaseOfWebId(session.webId)}`);
81
85
  const remote = new BrowserRemotePod(session, { webId: session.webId, role: 'signup', log: () => {} });
82
- const urls = { state };
83
- const [cfg, doc] = await Promise.all([
84
- podState.readConfig(remote, urls), podState.readKeys(remote, urls),
85
- ]);
86
- if (!cfg) throw new Error(`could not read this account's config under ${state}`);
87
- const actorUrl = `${cfg.remotePod}${cfg.root || AP_ROOT}ap/actor`;
88
- return { remote, urls, cfg, doc, actorUrl };
86
+ const urls = { state: `${here.pod}${here.root}ap-state/` };
87
+ const doc = await podState.readKeys(remote, urls);
88
+ const actorUrl = `${here.config.remotePod || here.pod}${here.config.root || here.root}ap/actor`;
89
+ return { remote, urls, cfg: here.config, doc, actorUrl };
89
90
  }
90
91
 
91
92
  window.fedipodUnlock = async (password) => {
@@ -161,22 +162,33 @@ export function parseAddress(input) {
161
162
  if (!handle || !host || !host.includes('.')) return null;
162
163
  return { handle, host };
163
164
  }
164
- async function issuerForPod(pod) {
165
+ async function issuerForActor(actorUrl) {
165
166
  // The pod's actor says where a client signs in (oauthAuthorizationEndpoint's
166
- // origin); failing that, the account provider is the pod host's parent
167
- // domain for a subdomain pod, and the host itself for a pod on a path.
167
+ // origin). Failing that: a host that answers OpenID discovery itself is the
168
+ // provider (a suffixed pod), and otherwise the provider is
169
+ // the pod host's parent domain (a subdomain pod).
168
170
  try {
169
- const authz = await podActor.readIssuer(`${pod}${AP_ROOT}ap/actor`);
171
+ const authz = await podActor.readIssuer(actorUrl);
170
172
  if (authz) return new URL(authz).origin;
171
173
  } catch { /* fall through */ }
172
- const u = new URL(pod);
173
- if (u.pathname !== '/') return u.origin;
174
+ const u = new URL(actorUrl);
175
+ const own = await fetch(`${u.origin}/.well-known/openid-configuration`, { headers: { accept: 'application/json' } }).catch(() => null);
176
+ if (own?.ok) return u.origin;
174
177
  const parent = u.host.split('.').slice(1).join('.');
175
178
  return `https://${parent || u.host}`;
176
179
  }
180
+ // An address on a pod names its actor through the pod host's own WebFinger;
181
+ // an older pod that does not answer is taken to keep it at `fedipod/`.
182
+ async function actorForPodAddress(handle, host) {
183
+ const res = await fetch(`https://${host}/.well-known/webfinger?resource=${encodeURIComponent(`acct:${handle}@${host}`)}`,
184
+ { headers: { accept: 'application/jrd+json, application/json' } }).catch(() => null);
185
+ const doc = res?.ok ? await res.json().catch(() => ({})) : {};
186
+ const self = (doc.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/u.test(l.type || ''));
187
+ return self?.href || `https://${host}/fedipod/ap/actor`;
188
+ }
177
189
  // An address at this site names a fronted identity. Its documents live on a
178
190
  // pod this site's WebFinger names as an alias — the pod's own actor id.
179
- async function podForFrontedAddress(handle) {
191
+ async function actorForFrontedAddress(handle) {
180
192
  const res = await fetch(`/.well-known/webfinger?resource=${encodeURIComponent(`acct:${handle}@${location.host}`)}`,
181
193
  { headers: { accept: 'application/jrd+json, application/json' } }).catch(() => null);
182
194
  if (res?.status === 410) throw new Error(`@${handle}@${location.host} is closed: nothing on the pod behind it was touched, but the address is gone for good.`);
@@ -184,19 +196,17 @@ async function podForFrontedAddress(handle) {
184
196
  const doc = await res.json().catch(() => ({}));
185
197
  const podActorId = (doc.aliases || []).find((a) => /\/ap\/actor$/u.test(String(a)));
186
198
  if (!podActorId) throw new Error(`@${handle}@${location.host} lives here but names no pod to sign in to`);
187
- const tail = `${AP_ROOT}ap/actor`;
188
- if (!podActorId.endsWith(tail)) throw new Error(`the pod actor ${podActorId} is not where a FediPod pod keeps one`);
189
- return podActorId.slice(0, -tail.length);
199
+ return podActorId;
190
200
  }
191
201
  window.fedipodSignin = async ({ address }) => {
192
202
  const parsed = parseAddress(address);
193
203
  if (!parsed) throw new Error('Enter your address as @you@yourpod (for example @alice@alice.solidcommunity.net).');
194
204
  const bad = handleProblem(parsed.handle);
195
205
  if (bad) throw new Error(bad);
196
- const pod = parsed.host === location.host.toLowerCase()
197
- ? await podForFrontedAddress(parsed.handle)
198
- : `https://${parsed.host}/`;
199
- const issuer = await issuerForPod(pod);
206
+ const actor = parsed.host === location.host.toLowerCase()
207
+ ? await actorForFrontedAddress(parsed.handle)
208
+ : await actorForPodAddress(parsed.handle, parsed.host);
209
+ const issuer = await issuerForActor(actor);
200
210
  const { authorizationUrl } = await beginLogin({ issuer, redirectUri: REDIRECT });
201
211
  location.href = authorizationUrl;
202
212
  };
@@ -238,6 +248,8 @@ window.fedipodSignOut = async () => {
238
248
  let req; try { req = indexedDB.deleteDatabase('fedipod-accounts'); } catch { res(); return; }
239
249
  req.onsuccess = req.onerror = req.onblocked = () => res();
240
250
  });
251
+ // The copy of the account's state kept for worker restarts goes with it.
252
+ await deleteWarm();
241
253
  };
242
254
  window.fedipodHandleProblem = handleProblem;
243
255
 
@@ -445,6 +457,10 @@ if (typeof document !== 'undefined') (async () => {
445
457
  $('loading').hidden = true; $('hero').hidden = true; $('landing').hidden = true; $('running').hidden = true;
446
458
  $('brand').hidden = false; $('pane-form').hidden = false;
447
459
  $('signed-pod').textContent = signedPod;
460
+ // The container that holds `fedipod/`: the pod's own root unless they say
461
+ // otherwise. A move keeps the place the account already has.
462
+ $('container').placeholder = podRootPath(signedPod);
463
+ $('container-field').hidden = !!moveFrom;
448
464
  $('movein-note').hidden = !moveFrom;
449
465
  if (moveFrom) {
450
466
  $('movein-from').textContent = moveFrom.address;
@@ -484,7 +500,13 @@ if (typeof document !== 'undefined') (async () => {
484
500
  const pathPod = () => { try { return new URL(signedPod).pathname !== '/'; } catch { return false; } };
485
501
  // A move between gateways is an address at a gateway: no choice either.
486
502
  const shape = () => ((pathPod() || moveFrom) ? 'front' : f().shape.value);
487
- const answers = () => ({ handle: f().handle.value.trim().toLowerCase(), shape: shape() });
503
+ const answers = () => ({ handle: f().handle.value.trim().toLowerCase(), shape: shape(), container: f().container.value.trim() });
504
+ // Where the data will be, in full, as the person types.
505
+ const previewPlace = () => {
506
+ if (!signedPod) return;
507
+ const r = chosenRoot(signedPod, f().container.value);
508
+ $('place').textContent = r.problem ? '…' : signedPod + r.root;
509
+ };
488
510
  const previewAddr = () => {
489
511
  const handle = f().handle.value.trim().toLowerCase();
490
512
  let host = '';
@@ -507,7 +529,7 @@ if (typeof document !== 'undefined') (async () => {
507
529
  $('register-link').href = url || '#';
508
530
  $('register-link').textContent = providerHost() ? `Create your pod at ${providerHost()}` : 'Create your pod at your provider';
509
531
  };
510
- for (const el of $('form').elements) for (const evt of ['input', 'change']) el.addEventListener(evt, () => { applyMode(); applyShape(); previewAddr(); });
532
+ for (const el of $('form').elements) for (const evt of ['input', 'change']) el.addEventListener(evt, () => { applyMode(); applyShape(); previewAddr(); previewPlace(); });
511
533
  applyMode();
512
534
 
513
535
  // Step machine: one screen at a time, each gated by its own validation.
@@ -516,7 +538,7 @@ if (typeof document !== 'undefined') (async () => {
516
538
  const goStep = (n) => {
517
539
  STEP_IDS.forEach((id, i) => { $(id).hidden = i !== n - 1; });
518
540
  $('err-1').textContent = ''; $('form-error').textContent = '';
519
- if (n === 2) { applyShape(); previewAddr(); }
541
+ if (n === 2) { applyShape(); previewAddr(); previewPlace(); $('index-ask').hidden = true; }
520
542
  if (FOCUS[n]) $(FOCUS[n]).focus();
521
543
  };
522
544
  const validateStep1 = () => {
@@ -526,6 +548,10 @@ if (typeof document !== 'undefined') (async () => {
526
548
  const validateStep2 = () => {
527
549
  const hp = window.fedipodHandleProblem(f().handle.value.trim().toLowerCase());
528
550
  if (hp) return `Fediverse handle: ${hp}`;
551
+ if (!moveFrom) {
552
+ const where = chosenRoot(signedPod, f().container.value).problem;
553
+ if (where) return `Where to store it: ${where}`;
554
+ }
529
555
  return null;
530
556
  };
531
557
  $('create').addEventListener('click', showForm);
@@ -549,12 +575,12 @@ if (typeof document !== 'undefined') (async () => {
549
575
  $('run-retry').addEventListener('click', () => backToForm($('run-error').textContent));
550
576
  $('run-back').addEventListener('click', () => { showLanding(); goStep(1); });
551
577
 
552
- const LABELS = { pod: 'Checking your pod', keys: 'Making your signing key', gateway: 'Connecting your mail door' };
578
+ const LABELS = { pod: 'Checking your pod', keys: 'Making your signing key', gateway: 'Connecting your mail door',
579
+ place: 'Recording where your account lives' };
553
580
  const MOVE_LABELS = { pod: 'Reading your account on your pod', gateway: 'Taking your address here', keys: 'Moving your key and account record' };
554
- $('form').addEventListener('submit', async (e) => {
555
- e.preventDefault(); $('form-error').textContent = '';
556
- const e2 = validateStep2(); if (e2) { $('form-error').textContent = e2; goStep(2); return; }
557
- const a = answers();
581
+ // Run the setup. `createIndex` is the person's yes to a new public type index.
582
+ const run = async (createIndex = false) => {
583
+ const a = { ...answers(), ...(createIndex ? { createIndex: true } : {}) };
558
584
  $('pane-form').hidden = true; $('running').hidden = false;
559
585
  $('running-title').textContent = 'Setting up…'; $('run-error').textContent = ''; $('run-actions').hidden = true;
560
586
  const steps = $('steps'); steps.textContent = ''; const mark = {};
@@ -566,10 +592,27 @@ if (typeof document !== 'undefined') (async () => {
566
592
  location.href = '/admin/client/';
567
593
  }
568
594
  catch (err) {
595
+ // No type index: nothing has been written. Ask, on the form.
596
+ if (err?.code === 'needs-index') {
597
+ backToForm();
598
+ $('index-ask').hidden = false;
599
+ $('index-yes').focus();
600
+ return;
601
+ }
569
602
  $('running-title').textContent = 'Setup did not finish';
570
603
  $('run-error').textContent = err.message || String(err);
571
604
  $('run-actions').hidden = false; // the way out of the failure screen
572
605
  }
606
+ };
607
+ $('form').addEventListener('submit', async (e) => {
608
+ e.preventDefault(); $('form-error').textContent = '';
609
+ const e2 = validateStep2(); if (e2) { $('form-error').textContent = e2; goStep(2); return; }
610
+ await run();
611
+ });
612
+ $('index-yes').addEventListener('click', () => { $('index-ask').hidden = true; run(true); });
613
+ $('index-no').addEventListener('click', () => {
614
+ $('index-ask').hidden = true;
615
+ $('form-error').textContent = 'Sign-up stopped. Nothing was written to your pod.';
573
616
  });
574
617
 
575
618
  if (pendingIdentity) await showIdentity();
@@ -0,0 +1,223 @@
1
+ // copy-mode.mjs — the browser agent working from its account's copy at the
2
+ // gateway (lib/gateway/copy.mjs, lib/gateway/state-api.mjs), for an account the
3
+ // gateway keeps running.
4
+ //
5
+ // The state documents are read and written at the gateway instead of on the
6
+ // pod, and the lease that says which agent acts is the copy's. What stays on
7
+ // the pod (the key, connected-account passwords) is still read there. The
8
+ // gateway writes the copy to the pod every fifteen minutes.
9
+ import { HttpStorage, StateApiStorage, podOnly } from '../../lib/core/storage.mjs';
10
+ import { Lease } from '../../lib/core/lease.mjs';
11
+
12
+ // A token this much short of its end is renewed before it is used again.
13
+ const RENEW_BEFORE_MS = 2 * 3600_000;
14
+
15
+ /**
16
+ * Ask the gateway for this account's copy, making it if there is none yet.
17
+ * `handle` names the account when the agent knows it. Returns the copy
18
+ * ({ handle, base, token, expiresAt }) or null, in which case the account
19
+ * works from its pod.
20
+ */
21
+ export async function openCopy(agent, frontOrigin, { handle = null, kept = null } = {}) {
22
+ if (kept && kept.expiresAt - Date.now() > RENEW_BEFORE_MS) return kept;
23
+ if (!agent.sessionFetch || !frontOrigin) return null;
24
+ let res;
25
+ try {
26
+ res = await agent.sessionFetch(`${frontOrigin.replace(/\/$/u, '')}/api/state/open`, {
27
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(handle ? { handle } : {}),
28
+ });
29
+ } catch (e) { agent.log(`the account's copy: ${e.message}`); return null; }
30
+ if (res.status !== 200) {
31
+ // 404, 409 and 501 are answers (no copy for this account here); anything
32
+ // else is worth saying.
33
+ if (![404, 409, 501].includes(res.status)) agent.log(`the account's copy: the gateway answered ${res.status}`);
34
+ return null;
35
+ }
36
+ const copy = await res.json().catch(() => null);
37
+ return copy?.base && copy?.token
38
+ ? { handle: copy.handle, base: copy.base, token: copy.token, expiresAt: copy.expiresAt, podHome: copy.podHome || null }
39
+ : null;
40
+ }
41
+
42
+ // Every request to the copy carries its token, read at the time of asking so a
43
+ // renewed one is used at once.
44
+ const tokenFetch = (agent) => (u, i = {}) => fetch(u, {
45
+ ...i, headers: { ...(i.headers || {}), authorization: `Bearer ${agent.copy.token}` },
46
+ });
47
+
48
+ /** The store's storage, and the lease, for working from the copy. */
49
+ export function copyStorage(agent, podState) {
50
+ const s = new StateApiStorage(agent.copy.base, {
51
+ fetchImpl: (u, i) => fetch(u, i), token: agent.copy.token, holder: agent.holderId, pod: podState,
52
+ // Refused: another agent (an app at the gateway, another browser) took
53
+ // the lease. This one stops acting at once rather than at its next renewal.
54
+ onRefused: () => standDown(agent),
55
+ });
56
+ Object.defineProperty(s, 'token', { get: () => agent.copy.token, set() {} });
57
+ return s;
58
+ }
59
+
60
+ export function copyLeaseOf(agent) {
61
+ return new Lease({ url: `${agent.copy.base}lease.json`, fetchImpl: tokenFetch(agent), log: agent.log, id: agent.holderId });
62
+ }
63
+
64
+ // Swap the lease the agent and its drain go by.
65
+ function useLease(agent, lease) {
66
+ agent.lease.stopRenewal();
67
+ agent.lease = lease;
68
+ if (agent.intake) agent.intake.lease = lease;
69
+ lease.onLost = () => agent.demote();
70
+ }
71
+
72
+ /**
73
+ * Before this browser acts: is the copy's lease still its own? An app at the
74
+ * gateway may have taken it and let it go since. Free again, it is taken back
75
+ * and the copy read afresh, so the action starts from what the app wrote.
76
+ * Held by somebody else, the browser stands down and the caller takes over as
77
+ * from any other device. True when this browser holds it now.
78
+ */
79
+ export async function ensureCopyLease(agent) {
80
+ const cur = await agent.lease.readFresh().catch(() => null);
81
+ if (!cur || typeof cur !== 'object') return true; // unreadable: the copy refuses if it is wrong
82
+ if (cur.holder === agent.lease.id && Date.now() < cur.expiresAt) return true;
83
+ // Held by another agent: this browser stands down, dropping what it had
84
+ // queued, and the caller takes over, reading the copy afresh.
85
+ if (Date.now() < cur.expiresAt) { agent.demote(); await agent.store.discardPending(); return false; }
86
+ // Free: an app acted and let it go. Writes queued here since are older than
87
+ // what the app wrote, so they are dropped, not sent.
88
+ agent._ensuring = true;
89
+ try {
90
+ await agent.store.discardPending();
91
+ if (!await agent.lease.acquire()) { agent.demote(); return false; }
92
+ await agent.store.load({ force: true });
93
+ agent.lease.startRenewal();
94
+ return true;
95
+ } finally { agent._ensuring = false; }
96
+ }
97
+
98
+ /**
99
+ * Another agent took the lease: an app acting at the gateway, which gives it
100
+ * back as soon as it is done. This browser stops acting at once, then takes
101
+ * the lease back when it is free, reading the copy afresh first (goActive), so
102
+ * it is not left watching for the viewer poll's five minutes. Another browser
103
+ * that took over keeps it, and the viewer poll carries on as before.
104
+ */
105
+ export function standDown(agent) {
106
+ if (agent._retaking || agent._ensuring) return;
107
+ if (!agent.viewer) agent.demote();
108
+ agent._retaking = (async () => {
109
+ try {
110
+ // What was queued before the other agent acted is older than what it wrote.
111
+ await agent.store.discardPending();
112
+ for (const wait of [3000, 20_000]) {
113
+ await new Promise((r) => setTimeout(r, wait));
114
+ if (!agent.viewer) return;
115
+ const cur = await agent.lease.readFresh().catch(() => null);
116
+ // Held by anyone but the gateway (copy.mjs: GATEWAY_HOLDER) is another browser's.
117
+ if (cur && typeof cur === 'object' && cur.holder !== 'gateway' && Date.now() < cur.expiresAt) return;
118
+ if (await agent.lease.acquire()) {
119
+ clearTimeout(agent._viewerTimer); agent._viewerTimer = null;
120
+ // Read what the app wrote, then carry on as a restart moments later
121
+ // would: no republishing, one drain, the inbox watched again.
122
+ await agent.store.load({ force: true });
123
+ const now = Date.now();
124
+ await agent.goActive({ warm: { hereAt: now, drainedAt: 0, wakeAt: now, resubscribe: true } });
125
+ agent.log('took the account back from the gateway');
126
+ return;
127
+ }
128
+ }
129
+ } finally { agent._retaking = null; }
130
+ })();
131
+ }
132
+
133
+ /**
134
+ * Move a running agent from its pod onto the copy: everything written, the
135
+ * pod's lease let go, the copy made from the pod, and the agent carried over
136
+ * holding the copy's lease. Returns whether it moved; when it could not, the
137
+ * agent takes the pod's lease back and goes on as it was.
138
+ */
139
+ export async function moveIntoCopy(agent, frontOrigin) {
140
+ if (agent.copy) return true;
141
+ await agent.store.commit();
142
+ await agent.lease.release();
143
+ const copy = await openCopy(agent, frontOrigin, { handle: agent.doorKey });
144
+ if (!copy) {
145
+ if (await agent.lease.acquire()) agent.lease.startRenewal();
146
+ return false;
147
+ }
148
+ agent.copy = copy;
149
+ const lease = copyLeaseOf(agent);
150
+ if (!await lease.acquire()) { agent.log('the account\'s copy is held by another agent; reading only'); }
151
+ agent.store.attach(copyStorage(agent, new HttpStorage(agent.urls.state, (u, i) => agent.remote.fetch(u, i))));
152
+ await agent.store.load({ force: true });
153
+ useLease(agent, lease);
154
+ if (lease.heldUntil) lease.startRenewal(); else agent.demote();
155
+ agent.log('working from the account\'s copy at the gateway');
156
+ return true;
157
+ }
158
+
159
+ /**
160
+ * Back to the pod: everything written, the copy's lease let go, the gateway
161
+ * asked to write the copy to the pod and give it up, and the agent carried
162
+ * back onto the pod. Returns { ok } or { ok: false, why }.
163
+ */
164
+ export async function leaveCopy(agent) {
165
+ if (!agent.copy) return { ok: true };
166
+ await agent.store.commit();
167
+ await agent.lease.release();
168
+ const res = await tokenFetch(agent)(`${agent.copy.base}leave`, { method: 'POST' }).catch((e) => ({ status: 0, e }));
169
+ if (res.status !== 200) {
170
+ if (await agent.lease.acquire()) agent.lease.startRenewal();
171
+ return { ok: false, why: `the gateway could not write the copy to the pod (${res.status || res.e?.message})` };
172
+ }
173
+ agent.copy = null;
174
+ const podFetch = (u, i) => agent.remote.fetch(u, i);
175
+ const lease = new Lease({ url: `${agent.urls.state}lease.json`, fetchImpl: podFetch, log: agent.log, id: agent.holderId });
176
+ agent.store.attach(new HttpStorage(agent.urls.state, podFetch));
177
+ await agent.store.load({ force: true });
178
+ useLease(agent, lease);
179
+ if (await lease.acquire()) lease.startRenewal(); else agent.demote();
180
+ agent.log('working from the pod again');
181
+ return { ok: true };
182
+ }
183
+
184
+ /**
185
+ * The gateway has a new identity, and this account's copy was kept under the
186
+ * old one, which the new one cannot write back for (the owner's rules name the
187
+ * old). The owner's own browser does it: every document in the copy written to
188
+ * the pod as the owner, the pod's lease taken, the gateway told to forget the
189
+ * copy, and the agent carried back onto the pod. Its rules then name the new
190
+ * identity as any first start does (setKeeper), and a new copy is made.
191
+ * Returns { ok } or { ok: false, why }; nothing is forgotten unless everything
192
+ * reached the pod.
193
+ */
194
+ export async function handOverCopy(agent) {
195
+ if (!agent.copy) return { ok: true };
196
+ await agent.store.commit();
197
+ const podFetch = (u, i) => agent.remote.fetch(u, i);
198
+ const pod = new HttpStorage(agent.urls.state, podFetch);
199
+ const docs = agent.store.names().filter((n) => !podOnly(n)).map((n) => [n, agent.store.read(n, null)]).filter(([, v]) => v !== null);
200
+ for (const [name, value] of docs) {
201
+ const w = await pod.write(name, JSON.stringify(value, null, 2) + '\n', 'application/json');
202
+ if (!w.ok) return { ok: false, why: `${name} could not be written to the pod (${w.why})` };
203
+ }
204
+ const podLease = new Lease({ url: `${agent.urls.state}lease.json`, fetchImpl: podFetch, log: agent.log, id: agent.holderId });
205
+ if (!await podLease.takeover()) return { ok: false, why: 'the pod\'s lease could not be taken' };
206
+ const res = await tokenFetch(agent)(`${agent.copy.base}forget`, { method: 'POST', headers: { 'x-fedipod-holder': agent.holderId } })
207
+ .catch((e) => ({ status: 0, e }));
208
+ if (res.status !== 200) return { ok: false, why: `the gateway would not let the copy go (${res.status || res.e?.message})` };
209
+ agent.copy = null;
210
+ agent.store.attach(pod);
211
+ await agent.store.load({ force: true });
212
+ useLease(agent, podLease);
213
+ podLease.startRenewal();
214
+ agent.log(`handed the account's copy over: ${docs.length} documents written to the pod`);
215
+ return { ok: true };
216
+ }
217
+
218
+ /** A token near its end, renewed; the hourly check-in asks. */
219
+ export async function renewCopyToken(agent, frontOrigin) {
220
+ if (!agent.copy || agent.copy.expiresAt - Date.now() > RENEW_BEFORE_MS) return;
221
+ const fresh = await openCopy(agent, frontOrigin, { handle: agent.copy.handle });
222
+ if (fresh) agent.copy = fresh;
223
+ }