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
@@ -33,7 +33,7 @@ export const ADMIN_PATHS = new Set([
33
33
  '/deadletter', '/blocks', '/profiles', '/modqueue', '/log', '/fediacct', '/describe', '/follow',
34
34
  '/atproto', '/atproto/connect', '/atproto/disconnect',
35
35
  '/rebuild', '/move', '/retire', '/inbox/prune', '/park', '/revive', '/takeover',
36
- '/gateway/pause', '/gateway/close',
36
+ '/gateway/pause', '/gateway/keep', '/gateway/close',
37
37
  '/fediacct/connect', '/fediacct/disconnect', '/fediacct/callback',
38
38
  ]);
39
39
 
@@ -143,6 +143,9 @@ export class AdminFacade {
143
143
  // gateway last said it (agent.mjs tellGateway). The record page
144
144
  // shows its pause and close controls only when this is here.
145
145
  standing: a.gatewayStanding || null,
146
+ // Whether the gateway may act for this account while the app is
147
+ // closed, and whether it does (agent.mjs setKeeper).
148
+ keeper: a._keeper ? { available: true, on: !!a._keeper.kept && !a.store.getConfig()?.keeperOff } : null,
146
149
  });
147
150
  }
148
151
  case '/deadletter': return json(200, { items: a.store.getDeadLetters() });
@@ -279,6 +282,13 @@ export class AdminFacade {
279
282
  if (!r) return json(502, { error: 'the gateway could not be reached' });
280
283
  return json(r.status === 200 ? 200 : r.status, r);
281
284
  }
285
+ case '/gateway/keep': {
286
+ if (typeof body.on !== 'boolean') return json(400, { error: 'on must be true or false' });
287
+ if (!a.setKeeper) return json(501, { error: 'this account is not at a gateway' });
288
+ if (!await a.requestTakeover?.()) return json(503, { error: 'another device is active for this pod — change it from there' });
289
+ const r = await a.setKeeper(body.on);
290
+ return json(r?.status === 200 ? 200 : (r?.status || 502), r || { error: 'the gateway could not be reached' });
291
+ }
282
292
  case '/gateway/close': {
283
293
  const handle = String(a.store.getConfig()?.handle || '').toLowerCase();
284
294
  if (!handle || String(body.confirm || '').toLowerCase() !== handle) return json(400, { error: 'type the handle to confirm' });
@@ -356,7 +366,7 @@ export class AdminFacade {
356
366
  const aliases = [...(cfg.aliases || [])];
357
367
  if (body.add) {
358
368
  if (!webfingerHost(urls.base) && !cfg.gateway?.frontActor) {
359
- return json(400, { error: 'this pod is a suffix-based host, so other servers could never resolve it as a Move target' });
369
+ return json(400, { error: 'this pod is suffixed, so other servers could never resolve it as a Move target' });
360
370
  }
361
371
  const id = await this.resolveActor(body.add);
362
372
  if (!id) return json(400, { error: `could not fetch the old account (${body.add}) — enter its URL or @user@host, and it must answer` });
package/web/app/agent.mjs CHANGED
@@ -6,7 +6,10 @@
6
6
  // the relay (deliver-relay). Everything between — wire, the store, the
7
7
  // publisher, intake, the Mastodon facade — is lib/, unchanged.
8
8
  import { kvGet, kvPut } from './idb-kv.mjs';
9
- import { apUrls } from '../../lib/core/wire.mjs';
9
+ import { warmMeta, saveMeta, loadDocs, keepInStep, takeUp, isWarm, DRAIN_EVERY_MS } from './warm-start.mjs';
10
+ import { publishDue, nextDue } from '../../lib/core/scheduled.mjs';
11
+ import { apUrls, DEFAULT_ROOT } from '../../lib/core/wire.mjs';
12
+ import { findAccount } from '../../lib/core/place.mjs';
10
13
  import * as containers from '../../lib/pod/containers.mjs';
11
14
  import * as podState from '../../lib/pod/state.mjs';
12
15
  import { PodStore } from '../../lib/core/store.mjs';
@@ -31,6 +34,7 @@ import { AcctFeed } from '../../lib/connections/acctfeed.mjs';
31
34
  import { followActor, unfollowActor, resolveHandle } from '../../lib/core/social.mjs';
32
35
  import { podBaseOfWebId } from '../../lib/pod/urls.mjs';
33
36
  import { ImportWorker } from '../../lib/connections/import.mjs';
37
+ import { openCopy, copyStorage, copyLeaseOf, moveIntoCopy, leaveCopy, renewCopyToken, standDown, ensureCopyLease, handOverCopy } from './copy-mode.mjs';
34
38
 
35
39
  // The authorities this identity answers on: exactly one, this origin. The Node
36
40
  // agent gets this from lib/guard.mjs, which is not in the browser bundle and
@@ -129,6 +133,9 @@ export class BrowserAgent {
129
133
  // the lease, proceed. If we are a viewer, the owner acting HERE outranks the
130
134
  // idle active device: claim the lease outright and become active.
131
135
  async requestTakeover() {
136
+ // Working from the copy, an app at the gateway may have taken the lease
137
+ // since this browser last looked: asked now, before acting (copy-mode.mjs).
138
+ if (!this.viewer && this.copy) await ensureCopyLease(this);
132
139
  if (!this.viewer) return true;
133
140
  if (!(await this.lease.takeover())) return false;
134
141
  clearTimeout(this._viewerTimer); this._viewerTimer = null;
@@ -159,14 +166,85 @@ export class BrowserAgent {
159
166
  closeAtGateway() { return this.tellGateway('close', { handle: this.doorKey, confirm: true }); }
160
167
  static OPEN_EVERY_MS = 60 * 60_000;
161
168
 
169
+ // The gateway acting for this account while the app is closed, or not. On:
170
+ // the rules on the account's folders name the gateway's pod identity, then
171
+ // the gateway is told. Off: the gateway is told first, so it stops, then the
172
+ // name comes out of the rules. Every public rule follows, because the
173
+ // published face's digest includes who the rules name.
174
+ async setKeeper(on) {
175
+ const webId = this._keeper?.webId;
176
+ if (!webId || !this.gatewayApi) return { status: 501, error: 'this gateway cannot act for accounts' };
177
+ this.store.setConfig({ ...this.store.getConfig(), keeperOff: !on });
178
+ if (!on) {
179
+ // The account's copy goes back to the pod first (copy-mode.mjs).
180
+ const left = await leaveCopy(this);
181
+ if (!left.ok) return { status: 502, error: left.why };
182
+ const said = await this.tellGateway('keeper', { handle: this.doorKey, on: false });
183
+ if (said?.status !== 200) return said || { status: 502 };
184
+ this._keeper.kept = false;
185
+ if (this.masto) this.masto.scheduling = false;
186
+ }
187
+ this.remote.keepers = on ? [webId] : [];
188
+ await containers.restateRules(this.remote, this.urls);
189
+ await this.publisher.publishProfile();
190
+ await this.store.flush?.();
191
+ if (!on) return { status: 200, ok: true, kept: false };
192
+ const said = await this.tellGateway('keeper', { handle: this.doorKey, on: true });
193
+ if (said?.status === 200) {
194
+ this._keeper.kept = true;
195
+ if (this.masto) this.masto.scheduling = true;
196
+ this.log('the gateway keeps this account running while the app is closed');
197
+ }
198
+ return said || { status: 502 };
199
+ }
200
+
201
+ // A post just scheduled: the gateway hears when the next one falls due.
202
+ onScheduled() { this.hereAtGateway().catch(() => {}); }
203
+
204
+ // "The app is open", every five minutes while it is. While it is, the
205
+ // gateway sends mail straight to the pod; while it is not, it holds the mail
206
+ // and hands it over in batches, and the answer says how many it just did
207
+ // (lib/gateway/held-mail.mjs).
208
+ static HERE_EVERY_MS = 5 * 60_000;
209
+ async hereAtGateway() {
210
+ const said = await this.tellGateway('here', { handle: this.doorKey, nextAt: this.store ? nextDue(this.store) : null });
211
+ if (said?.status === 200) saveMeta(this.webId, { hereAt: Date.now() }).catch(() => {});
212
+ return said;
213
+ }
214
+
215
+ // The hourly "still here". A worker the browser killed and restarted is the
216
+ // same person, not a new sign-in: within the hour it answers from the last
217
+ // check-in, kept in this browser, instead of telling the gateway again.
218
+ async checkInAtGateway({ always = true } = {}) {
219
+ if (!this.gatewayApi) return null;
220
+ const key = `gateway-open:${this.gatewayApi}:${this.doorKey}`;
221
+ if (!always) {
222
+ const last = await kvGet(key).catch(() => null);
223
+ if (last?.standing && Date.now() - last.at < BrowserAgent.OPEN_EVERY_MS) {
224
+ this.gatewayStanding = last.standing;
225
+ return last.standing;
226
+ }
227
+ }
228
+ const standing = await this.openAtGateway();
229
+ if (standing?.status === 200 || standing?.status === 410) {
230
+ await kvPut(key, { at: Date.now(), standing }).catch(() => {});
231
+ }
232
+ await renewCopyToken(this, this.frontOrigin).catch(() => {});
233
+ return standing;
234
+ }
235
+
162
236
  // Become the active agent: renew the lease, then start what a viewer skips —
163
- // publish the face, drain the inbox, run the mirrors.
164
- async goActive() {
237
+ // publish the face, drain the inbox, run the mirrors. `warm` is a worker the
238
+ // browser restarted moments ago (warm-start.mjs): what a full start does once
239
+ // was done then, and the inbox is drained only when its cadence is due.
240
+ async goActive({ warm = null } = {}) {
165
241
  this.viewer = false;
166
242
  // Reading here is being here: the gateway hears so once an hour.
167
243
  clearInterval(this._openTimer);
168
- this._openTimer = setInterval(() => { this.openAtGateway(); }, BrowserAgent.OPEN_EVERY_MS);
169
- this.lease.onLost = () => this.demote();
244
+ this._openTimer = setInterval(() => { this.checkInAtGateway(); }, BrowserAgent.OPEN_EVERY_MS);
245
+ // Working from the copy, an app at the gateway takes the lease only while
246
+ // it acts, and this browser takes it back (copy-mode.mjs).
247
+ this.lease.onLost = () => (this.copy ? standDown(this) : this.demote());
170
248
  this.lease.startRenewal();
171
249
  try {
172
250
  // Forced, not revalidated, when this device WATCHED first: the active
@@ -175,27 +253,67 @@ export class BrowserAgent {
175
253
  // would push a whole document back over newer state. A device that
176
254
  // booted straight into acting read everything a moment ago; asking
177
255
  // again is one revalidation, not a second download of every document.
178
- await this.store.load({ force: !!this._watched }).catch((e) => this.log(`re-reading state: ${e.message}`));
179
- this._watched = false;
180
- // Own posts the outbox names and the timeline index lacks come back
181
- // here, before anything acts on the index.
182
- await this.publisher.healStatuses().catch((e) => this.log(`healing the timeline index: ${e.message}`));
183
- await this.publisher.publishProfilePage().catch((e) => this.log(`profile page: ${e.message}`));
256
+ if (!warm) {
257
+ await this.store.load({ force: !!this._watched }).catch((e) => this.log(`re-reading state: ${e.message}`));
258
+ this._watched = false;
259
+ // Own posts the outbox names and the timeline index lacks come back
260
+ // here, before anything acts on the index.
261
+ await this.publisher.healStatuses().catch((e) => this.log(`healing the timeline index: ${e.message}`));
262
+ // Likes made before the liked list existed become its first entries.
263
+ try { this.publisher.backfillLiked(); } catch (e) { this.log(`liked list: ${e.message}`); }
264
+ await this.publisher.publishProfilePage().catch((e) => this.log(`profile page: ${e.message}`));
265
+ }
184
266
  // And start delivering again, since demote() stopped it. startQueue() is
185
267
  // idempotent, so a goActive() that was already active costs nothing.
186
268
  this.deliverer?.startQueue?.();
187
- await this.publisher.publishProfile();
188
- await this.store.flush?.();
189
- // An address that just moved here from another gateway: the new
190
- // actor is published, so the old gateway and the followers can be told.
191
- await completeGatewayMove(this).catch((e) => this.log(`gateway move: ${e.message}`));
192
- await this.intake.start();
269
+ if (!warm) {
270
+ await this.publisher.publishProfile();
271
+ await this.store.flush?.();
272
+ // An address that just moved here from another gateway: the new
273
+ // actor is published, so the old gateway and the followers can be told.
274
+ await completeGatewayMove(this).catch((e) => this.log(`gateway move: ${e.message}`));
275
+ // The gateway has a new identity since this account's copy was made:
276
+ // the copy goes to the pod from here, and the new one is named below.
277
+ if (this.copy && this._keeper?.keptBy && this._keeper.keptBy !== this._keeper.webId) {
278
+ const moved = await handOverCopy(this).catch((e) => ({ ok: false, why: e.message }));
279
+ if (!moved.ok) this.log(`handing the copy over to the gateway's new identity: ${moved.why}`);
280
+ }
281
+ // The first start that can: the gateway may act for this account while
282
+ // the app is closed, unless its owner said no (setKeeper).
283
+ if (this._keeper && !this._keeper.kept && !this.copy && !this.store.getConfig()?.keeperOff) {
284
+ await this.setKeeper(true).catch((e) => this.log(`keeping the account while away: ${e.message}`));
285
+ }
286
+ // Kept: from here on the account works from its copy at the gateway.
287
+ if (this._keeper?.kept && !this.copy && !this.store.getConfig()?.keeperOff) {
288
+ await moveIntoCopy(this, this.frontOrigin).catch((e) => this.log(`the account's copy: ${e.message}`));
289
+ if (this.viewer) return; // another agent holds the copy: reading only
290
+ }
291
+ }
292
+ const now = Date.now();
293
+ // Said before the drain starts, so held mail is in the inbox to be read.
294
+ const hereDue = !warm || now - (warm.hereAt || 0) >= BrowserAgent.HERE_EVERY_MS;
295
+ const here = hereDue ? await this.hereAtGateway() : null;
296
+ clearInterval(this._hereTimer);
297
+ this._hereTimer = setInterval(() => {
298
+ this.hereAtGateway().then((h) => { if (h?.flushed) this.intake?.drain().catch(() => {}); });
299
+ }, BrowserAgent.HERE_EVERY_MS);
300
+ // Scheduled posts and polls whose time is up (lib/core/scheduled.mjs).
301
+ await publishDue(this.store, this.publisher, this.log).catch((e) => this.log(`scheduled posts: ${e.message}`));
302
+ clearInterval(this._schedTimer);
303
+ this._schedTimer = setInterval(() => { publishDue(this.store, this.publisher, this.log).catch(() => {}); }, 30_000);
304
+ const drainNow = !warm || now - (warm.drainedAt || 0) >= DRAIN_EVERY_MS || here?.flushed > 0;
305
+ await this.intake.start({ drainNow, subscribe: !warm || !!warm.resubscribe });
193
306
  this.startBsky();
194
307
  this.startAccts();
195
- this.tagfeed?.start();
308
+ this.tagfeed?.start(this.mirrorStart('tags'));
196
309
  // Resumes a run that was staged on another device, or here before a
197
310
  // restart — the state lives on the pod, not in this process.
198
311
  this.importer?.start();
312
+ // What the next restart may take up.
313
+ saveMeta(this.webId, warm
314
+ ? { wakeAt: now, copy: this.copy || null, ...(drainNow ? { drainedAt: now } : {}) }
315
+ : { root: this.store.getConfig()?.root || null, copy: this.copy || null, fullAt: now, wakeAt: now, drainedAt: now })
316
+ .catch(() => {});
199
317
  } catch (e) { this.log(`going active: ${e.message}`); }
200
318
  }
201
319
 
@@ -208,6 +326,8 @@ export class BrowserAgent {
208
326
  this.log('another device took over — read-only here');
209
327
  this.lease.stopRenewal();
210
328
  clearInterval(this._openTimer); this._openTimer = null;
329
+ clearInterval(this._hereTimer); this._hereTimer = null;
330
+ clearInterval(this._schedTimer); this._schedTimer = null;
211
331
  this.intake?.stop?.();
212
332
  // The delivery queue as well. Its timer starts in the Deliverer's
213
333
  // constructor and nothing here ever switched it off, so a demoted device
@@ -273,7 +393,7 @@ export class BrowserAgent {
273
393
  }
274
394
  }
275
395
 
276
- async boot({ oidc, credential, keysRecord, config, frontOrigin }) {
396
+ async boot({ oidc, credential, keysRecord, config, frontOrigin, restart = false }) {
277
397
  let session; let webId; let remotePod;
278
398
  if (oidc) {
279
399
  session = { fetch: (u, i) => oidc.fetch(u, i) };
@@ -289,8 +409,26 @@ export class BrowserAgent {
289
409
  // The pod session's own fetch, for the gateway APIs that take it as
290
410
  // proof of the pod (attach, move) — the transport below is for the pod.
291
411
  this.sessionFetch = session.fetch;
292
- const root = (config && config.root) || 'fedipod/';
293
412
  this.remote = new BrowserRemotePod(session, { webId, log: this.log });
413
+ // Where the account lives: handed in on sign-up, else what its owner's type
414
+ // index records, else `fedipod/` for an account older than the choice.
415
+ // A worker the browser restarted moments ago may take up what its last
416
+ // start read (warm-start.mjs) instead of reading the pod again.
417
+ const warmFrom = restart ? await warmMeta(webId).catch(() => null) : null;
418
+ // This browser's lease id, and the account's copy at the gateway when the
419
+ // gateway keeps the account running (copy-mode.mjs): asked before any state
420
+ // is read, since it decides where the state is read from.
421
+ this.holderId = await this.deviceId();
422
+ this.frontOrigin = frontOrigin || null;
423
+ this.copy = await openCopy(this, this.frontOrigin, { kept: isWarm(warmFrom) ? warmFrom.copy : null });
424
+ const warmRoot = isWarm(warmFrom) && !!warmFrom.copy === !!this.copy ? warmFrom.root : null;
425
+ const copyRoot = this.copy?.podHome?.startsWith(remotePod) ? this.copy.podHome.slice(remotePod.length) : null;
426
+ this._sweptAt = { ...(warmFrom?.sweptAt || {}) };
427
+ const root = (config && config.root) || warmRoot || copyRoot
428
+ || (await findAccount(this.remote, remotePod,
429
+ (r) => podState.readConfig(this.remote, { state: `${remotePod}${r}ap-state/` }),
430
+ (c) => (c.kind || 'person') === 'person').catch(() => null))?.root
431
+ || DEFAULT_ROOT;
294
432
  // Pod-native for now: the state store below is read with these, and only
295
433
  // the config it holds says whether this identity is fronted.
296
434
  this.urls = apUrls(remotePod, root);
@@ -301,7 +439,32 @@ export class BrowserAgent {
301
439
  // the deletion deny-list that every other write on this agent observes.
302
440
  // The Node agent has always passed remote.fetch here.
303
441
  const podFetch = (u, i) => this.remote.fetch(u, i);
304
- this.store = new PodStore({ storage: new HttpStorage(this.urls.state, podFetch), log: this.log });
442
+ const podStateStorage = new HttpStorage(this.urls.state, podFetch);
443
+ this.store = new PodStore({ storage: this.copy ? copyStorage(this, podStateStorage) : podStateStorage, log: this.log });
444
+ keepInStep(this.store, webId, this.log);
445
+ // Single-active-agent lease (lib/lease.mjs): the inbox drain is a
446
+ // destructive read and the state store is write-through-cached, so exactly
447
+ // one browser/device may ACT on a pod at a time — a later arrival runs
448
+ // read-only until the owner acts on it and it takes over. Written with fresh
449
+ // fetches, never the cached store.
450
+ // The holder id is kept on this origin, so THIS browser is one holder
451
+ // however many times its worker is killed and restarted. Without it every
452
+ // restart was a new holder: the old lease still had minutes to run, so the
453
+ // browser found its own account "active on another device" and asked to
454
+ // take it over. Switching between the clients did it every time, being a
455
+ // navigation. Working from the copy, the lease is the copy's.
456
+ this.lease = this.copy ? copyLeaseOf(this)
457
+ : new Lease({ url: this.urls.state + 'lease.json', fetchImpl: podFetch, log: this.log, id: this.holderId });
458
+ // What was kept is used only while the lease is still this browser's:
459
+ // nobody else can have written the state in between.
460
+ this._warm = null;
461
+ if (warmRoot && warmRoot === root) {
462
+ const held = await this.lease.resume().catch(() => null);
463
+ if (held) {
464
+ const pending = takeUp(this.store, await loadDocs(webId).catch(() => []), warmFrom.listingEtag);
465
+ this._warm = { ...warmFrom, renewDue: held.renewDue, pending };
466
+ }
467
+ }
305
468
  // A read that FAILED is not an account that is not there. load() already
306
469
  // tells those apart — a missing container, a refused token, a pod saying
307
470
  // "too many requests" each come back with their own reason — and throwing
@@ -310,7 +473,10 @@ export class BrowserAgent {
310
473
  // unread. Keep it: below is the only place that can tell whether it
311
474
  // mattered, because a sign-up hands its config in and needs no read at all.
312
475
  let unread = null;
313
- await this.store.load().catch((e) => { unread = e; });
476
+ const fullLoad = () => this.store.load().catch((e) => { unread = e; });
477
+ // Warm: only documents caught mid-write are read again, else a full load.
478
+ if (this._warm) await this.store.refresh(this._warm.pending).catch(fullLoad);
479
+ else await fullLoad();
314
480
  // Config: handed in on sign-up, or read from the pod on a returning sign-in.
315
481
  const cfg = config || this.store.getConfig();
316
482
  if (!cfg) throw await accountNotRead({ unread, podBase: remotePod, state: this.urls.state, webId, actorUrl: this.urls.actor });
@@ -355,15 +521,16 @@ export class BrowserAgent {
355
521
  // standing there — paused, closed, how much arrived unread (front-core:
356
522
  // accounts that go quiet). The gateway is told on every sign-in and
357
523
  // hourly while this device is active: that is what keeps an account
358
- // somebody reads from counting as a quiet one. Only a closed address
359
- // stops the boot; a gateway that cannot be reached is no reason to
360
- // refuse a sign-in.
524
+ // somebody reads from counting as a quiet one. A worker restart is not a
525
+ // sign-in and tells it only when the last check-in is an hour old. Only a
526
+ // closed address stops the boot; a gateway that cannot be reached is no
527
+ // reason to refuse a sign-in.
361
528
  // At the page's own origin, as the relay is: a page on the test alias
362
529
  // asked the real site instead and could not get past its preflight.
363
530
  this.gatewayApi = config.gateway?.url ? `${frontOrigin.replace(/\/$/, '')}/api` : null;
364
531
  this.doorKey = doorKeyOf(config.gateway?.url) || config.handle;
365
532
  this.gatewayStanding = null;
366
- const standing = await this.openAtGateway();
533
+ const standing = await this.checkInAtGateway({ always: !restart });
367
534
  if (standing?.status === 410) {
368
535
  const host = (() => { try { return new URL(this.gatewayApi).host; } catch { return 'the gateway'; } })();
369
536
  const address = this.doorKey.includes('@') ? `@${this.doorKey}` : `@${this.doorKey}@${host}`;
@@ -373,6 +540,11 @@ export class BrowserAgent {
373
540
  e.code = 'address-closed';
374
541
  throw e;
375
542
  }
543
+ // The gateway's own pod identity, when it can act for this account while
544
+ // the app is closed (lib/gateway/keeper.mjs). Unless the owner turned that
545
+ // off, every rule this agent writes names it beside the owner.
546
+ this._keeper = standing?.keeper ? { webId: standing.keeper, kept: !!standing.kept, keptBy: standing.keptBy || null } : null;
547
+ if (this._keeper && !this.store.getConfig()?.keeperOff) this.remote.keepers = [this._keeper.webId];
376
548
 
377
549
  this.publisher = new Publisher({
378
550
  config: this.store.getConfig(), remote: this.remote, store: this.store,
@@ -405,18 +577,8 @@ export class BrowserAgent {
405
577
  // throttled request hangs the whole sign-in. So it runs in the background,
406
578
  // where per-request retry rides out the throttling; `this.provisioning`
407
579
  // resolves when it is done (a test, or a later call, can await it).
408
- // Single-active-agent lease (lib/lease.mjs): the inbox drain is a
409
- // destructive read and the state store is write-through-cached, so exactly
410
- // one browser/device may ACT on a pod at a time — a later arrival runs
411
- // read-only until the owner acts on it and it takes over. Written with fresh
412
- // fetches, never the cached store. Passed into Intake so the drain checks it.
413
- this.lease = new Lease({ url: this.urls.state + 'lease.json', fetchImpl: podFetch, log: this.log,
414
- // Kept on this origin, so THIS browser is one holder however many times
415
- // its worker is killed and restarted. Without it every restart was a new
416
- // holder: the old lease still had minutes to run, so the browser found
417
- // its own account "active on another device" and asked to take it over.
418
- // Switching between the clients did it every time, being a navigation.
419
- id: await this.deviceId() });
580
+ // The lease (made above, before the first state read) goes into Intake so
581
+ // the drain checks it.
420
582
  // The client-to-server dispatcher, here only for what the Gateway's
421
583
  // outbox door takes on the owner's behalf: the browser answers no
422
584
  // /ap/outbox of its own.
@@ -433,15 +595,16 @@ export class BrowserAgent {
433
595
  // policy (lib/mastoapi.mjs:315). So any page could have a freshly minted
434
596
  // 90-day bearer delivered to an address of its own. One origin, ours, is
435
597
  // the whole of the policy here.
436
- // Three capabilities this build does not have, declared rather than
598
+ // Capabilities this build has only in part, declared rather than
437
599
  // pretended: no streaming socket (a worker is fetch-only), no web push
438
- // (shims/web-push.mjs is a no-op), and no scheduling (nothing here runs
439
- // between now and the scheduled time to publish). Each is omitted from the
440
- // instance document, so the client hides the control instead of offering
441
- // one that quietly does nothing.
600
+ // (shims/web-push.mjs is a no-op), and scheduling only while the gateway
601
+ // keeps the account running, because then something is running when the
602
+ // time comes (setKeeper). Each is omitted from the instance document when
603
+ // off, so the client hides the control instead of offering one that
604
+ // quietly does nothing.
442
605
  this.masto = new MastoApi({
443
606
  agent: this, log: this.log, scheme: 'https',
444
- streaming: false, webPush: false, scheduling: false,
607
+ streaming: false, webPush: false, scheduling: !!(this._keeper?.kept && !this.store.getConfig()?.keeperOff),
445
608
  allowed: originAuthorities(self.location.host),
446
609
  });
447
610
 
@@ -472,21 +635,23 @@ export class BrowserAgent {
472
635
  this.provisioning = (async () => {
473
636
  // Owner-only containers first; idempotent, so a second device provisioning
474
637
  // them too is harmless. Each public document sets its own Read ACL inside
475
- // publishProfile. Mirrors run-agent bootstrap.
476
- await containers.provisionPrivate(this.remote, this.urls);
638
+ // publishProfile. Mirrors run-agent bootstrap. A warm restart did this at
639
+ // its last full start.
640
+ if (!this._warm) await containers.provisionPrivate(this.remote, this.urls);
477
641
  // Connected-account records are reads — safe whether we act or view.
478
642
  await this.atproto.load(); // the Bluesky credential, if it is browser-stored
479
643
  await this.fediaccts.load(); // connected fediverse accounts, from both backends
480
644
  // The lease decides: this device ACTS on the pod, or reads it read-only.
481
- this.viewer = !(await this.lease.acquire());
645
+ // A warm restart already holds it, and writes it only when renewal is due.
646
+ this.viewer = !(this._warm && !this._warm.renewDue ? true : await this.lease.acquire());
482
647
  if (this.viewer) {
483
648
  this._watched = true;
484
649
  this.log(`read-only viewer: another device is active on @${config.handle}`);
485
650
  this.startViewerPoll(); // reload the feed, and promote if the lease frees
486
651
  return;
487
652
  }
488
- await this.goActive();
489
- this.log(`browser agent fully provisioned (active): @${config.handle}`);
653
+ await this.goActive({ warm: this._warm });
654
+ this.log(`browser agent ${this._warm ? 'resumed' : 'fully provisioned'} (active): @${config.handle}`);
490
655
  })().catch((e) => { this.log(`background provisioning: ${e.message}`); throw e; });
491
656
  // Never let an unhandled rejection escape when nobody awaits it.
492
657
  this.provisioning.catch(() => {});
@@ -551,7 +716,7 @@ export class BrowserAgent {
551
716
  if (!this.atproto?.feedActive()) return; // connected AND not paused
552
717
  if (this.store.getConfig()?.quiescedAt) return;
553
718
  this.bskyfeed ||= new BskyFeed({ store: this.store, atproto: this.atproto, log: this.log });
554
- this.bskyfeed.start();
719
+ this.bskyfeed.start(this.mirrorStart('bsky'));
555
720
  }
556
721
 
557
722
  stopBsky() { this.bskyfeed?.stop(); }
@@ -568,7 +733,19 @@ export class BrowserAgent {
568
733
  if (!this.fediaccts?.connected()) return;
569
734
  if (this.store.getConfig()?.quiescedAt) return;
570
735
  this.acctfeed ||= new AcctFeed({ store: this.store, accounts: this.fediaccts, log: this.log });
571
- this.acctfeed.start();
736
+ this.acctfeed.start(this.mirrorStart('accts'));
737
+ }
738
+
739
+ // When each mirror last swept, kept in this browser (warm-start.mjs), so a
740
+ // worker restart does not sweep it again before its interval is up.
741
+ mirrorStart(name) {
742
+ return {
743
+ lastSweptAt: this._sweptAt?.[name] || 0,
744
+ onSwept: (at) => {
745
+ this._sweptAt = { ...this._sweptAt, [name]: at };
746
+ saveMeta(this.webId, { sweptAt: this._sweptAt }).catch(() => {});
747
+ },
748
+ };
572
749
  }
573
750
 
574
751
  stopAccts() { this.acctfeed?.stop(); }