fedipod 1.45.0 → 1.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,43 +19,27 @@ async function refreshGateway() {
19
19
  GW_OPEN_ATTACH.hidden = true;
20
20
  GW_OPEN_DETACH.hidden = false;
21
21
  // The account's standing at the gateway — paused, closed. The browser
22
- // build's agent reports it; a DeviceAgent's does not, and the pause and
23
- // close controls stay hidden.
22
+ // build's agent reports it; a DeviceAgent's does not, and the keep
23
+ // control stays hidden.
24
24
  const st = g.standing;
25
25
  const known = !!st && (st.status === 200 || st.status === 410);
26
- $('gateway-pause').hidden = !known || !!st.closed || !!st.paused;
27
- $('gateway-resume').hidden = !known || !!st.closed || !st.paused;
28
- $('gateway-close').hidden = !known || !!st.closed;
29
26
  if (known && st.closed) GATEWAY_WORD.textContent += ' — this address is closed';
30
27
  else if (known && st.paused) GATEWAY_WORD.textContent += st.pausedBy === 'owner' ? ' — paused by you' : ' — paused';
31
- // Whether the gateway keeps the account running while FediPod is closed.
28
+ // Kept running while FediPod is closed; offered only to an account that is not.
32
29
  const kp = g.keeper;
33
30
  $('gateway-keep-on').hidden = !kp || !known || !!st.closed || kp.on;
34
- $('gateway-keep-off').hidden = !kp || !known || !!st.closed || !kp.on;
35
- if (kp?.on && known && !st.closed) GATEWAY_WORD.textContent += ' — kept running while you are away';
36
31
  } else {
37
32
  GATEWAY_WORD.textContent = '';
38
33
  GW_OPEN_ATTACH.hidden = false;
39
34
  GW_OPEN_DETACH.hidden = true;
40
- for (const id of ['gateway-pause', 'gateway-resume', 'gateway-keep-on', 'gateway-keep-off', 'gateway-close']) $(id).hidden = true;
35
+ $('gateway-keep-on').hidden = true;
41
36
  }
42
37
  }
43
- const setPausedAtGateway = async (paused) => {
44
- const r = await write('/gateway/pause', { paused },
45
- paused ? 'paused — posts sent to you are not kept until you resume; follows still arrive'
46
- : 'resumed — posts sent to you are kept again');
38
+ $('gateway-keep-on').addEventListener('click', async () => {
39
+ const r = await write('/gateway/keep', { on: true },
40
+ 'kept running — follows, deliveries to try again and scheduled posts are handled while FediPod is closed');
47
41
  if (r) refreshGateway();
48
- };
49
- $('gateway-pause').addEventListener('click', () => setPausedAtGateway(true));
50
- $('gateway-resume').addEventListener('click', () => setPausedAtGateway(false));
51
- const setKept = async (on) => {
52
- const r = await write('/gateway/keep', { on },
53
- on ? 'kept running — follows, deliveries to try again and scheduled posts are handled while FediPod is closed'
54
- : 'stopped — your account acts only while FediPod is open');
55
- if (r) refreshGateway();
56
- };
57
- $('gateway-keep-on').addEventListener('click', () => setKept(true));
58
- $('gateway-keep-off').addEventListener('click', () => setKept(false));
42
+ });
59
43
  const gwShape = () => document.querySelector('input[name=gwShape]:checked')?.value || 'pod';
60
44
  function gwPreviews() {
61
45
  $('gw-pod-preview').textContent = config?.address || `@${config?.handle || 'you'}@your.pod`;
@@ -340,16 +340,8 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
340
340
  title=" Go back to your pod's own inbox">Detach from gateway</button>
341
341
  <!-- Shown only when the agent reports the account's standing at the gateway
342
342
  (the browser build does; a DeviceAgent's admin does not). -->
343
- <button type="button" id="gateway-pause" class="inline" hidden
344
- title=" Stop keeping the posts sent to you until you resume; follows still arrive">Pause my account</button>
345
- <button type="button" id="gateway-resume" class="inline" hidden
346
- title=" Keep the posts sent to you again">Resume my account</button>
347
343
  <button type="button" id="gateway-keep-on" class="inline" hidden
348
344
  title=" Let this gateway accept follows, try again what failed to go out and publish scheduled posts while FediPod is closed">Keep my account running while I'm away</button>
349
- <button type="button" id="gateway-keep-off" class="inline" hidden
350
- title=" Act only while FediPod is open; the rule naming this gateway comes off your pod">Stop keeping it running</button>
351
- <button type="button" id="gateway-close" class="inline danger" hidden data-confirm="close-address"
352
- title=" Close your address at this gateway for good; nothing on your pod is touched">Close this address</button>
353
345
  </span>
354
346
 
355
347
  <section id="pane-group" hidden>
@@ -545,14 +537,6 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
545
537
  <label for="confirm-handle">Type the handle to confirm</label>
546
538
  <input type="text" id="confirm-handle" autocomplete="off">
547
539
  </div>
548
- <div id="warn-close-address" class="warn" hidden>
549
- <p><strong>This cannot be undone.</strong> Your address at the gateway is closed for good:
550
- mail to it is turned away, other servers drop the account the next time they look, and
551
- nobody can take the name. Nothing on your pod is touched — your posts, key and data stay
552
- where they are.</p>
553
- <label for="confirm-handle-close">Type the handle to confirm</label>
554
- <input type="text" id="confirm-handle-close" autocomplete="off">
555
- </div>
556
540
  <!-- "Move private data" (POST /state-move) had a panel here and no way to
557
541
  open it: nothing in this page has ever carried data-confirm="move-state",
558
542
  so the panel was only ever hidden. Removed 2026-09-09 rather than left as
@@ -94,7 +94,6 @@ function resetConfirm() {
94
94
  for (const k of Object.keys(LIFECYCLE)) $(`warn-${k}`)?.hidden !== undefined && ($(`warn-${k}`).hidden = true);
95
95
  $('confirm-handle').value = '';
96
96
  $('confirm-handle-move').value = '';
97
- $('confirm-handle-close').value = '';
98
97
  $('move-target').value = '';
99
98
  }
100
99
  function closePanels(keep = null) {
@@ -325,7 +324,7 @@ function render() {
325
324
  dd.textContent = '';
326
325
  dd.append(FOLLOWS_CTL);
327
326
  FOLLOWS_CTL.hidden = false;
328
- FOLLOWS_PICK.value = config.autoAcceptFollows ? 'auto' : 'approve';
327
+ FOLLOWS_PICK.value = config.autoAcceptFollows !== false ? 'auto' : 'approve';
329
328
  }
330
329
  if (k === 'gateway') {
331
330
  dd.textContent = '';
@@ -76,9 +76,6 @@ const LIFECYCLE = {
76
76
  retire: { path: '/retire', title: 'Retire this identity', go: 'Retire it', danger: true, done: (r) => `retired ${r.deletedAt}: Delete delivered to ${r.inboxes} inbox(es)` },
77
77
  move: { path: '/move', title: 'Transfer this account away', go: 'Transfer it', focus: 'move-target',
78
78
  done: (r) => `transferred to ${r.target}: Move delivered to ${r.inboxes} inbox(es), unfollowed ${r.unfollowed}/${r.following}` },
79
- // The address at the gateway, not the identity: the pod keeps everything.
80
- 'close-address': { path: '/gateway/close', title: 'Close this address', go: 'Close it', danger: true, focus: 'confirm-handle-close',
81
- done: (r) => (r.closed ? 'closed — this address is gone for good; everything on your pod is untouched' : 'not closed') },
82
79
  };
83
80
  let pending = null;
84
81
 
@@ -122,8 +119,7 @@ $('confirm-form').addEventListener('submit', async (ev) => {
122
119
  const what = pending;
123
120
  const body = what === 'retire' ? { confirm: $('confirm-handle').value.trim() }
124
121
  : what === 'move' ? { target: $('move-target').value.trim(), confirm: $('confirm-handle-move').value.trim() }
125
- : what === 'close-address' ? { confirm: $('confirm-handle-close').value.trim() }
126
- : {};
122
+ : {};
127
123
  if (what === 'move' && !body.target) { say('name the account to transfer to', 'err'); return; }
128
124
  $('confirm-go').disabled = true;
129
125
  say(`${what} — this talks to the pod and to other servers, so it takes a moment`);
@@ -120,7 +120,7 @@ export class AdminFacade {
120
120
  name: cfg.name || null, summary: cfg.summary || null, icon: cfg.icon || null,
121
121
  image: cfg.image || null, fields: cfg.fields || [],
122
122
  aliases: cfg.aliases || [],
123
- autoAcceptFollows: !!cfg.autoAcceptFollows,
123
+ autoAcceptFollows: cfg.autoAcceptFollows !== false,
124
124
  hasUiPassword: !!cfg.uiPassword,
125
125
  quiescedAt: cfg.quiescedAt || null, movedTo: cfg.movedTo || null,
126
126
  mode: a.status().mode,
package/web/app/agent.mjs CHANGED
@@ -13,7 +13,6 @@ import { findAccount } from '../../lib/core/place.mjs';
13
13
  import * as containers from '../../lib/pod/containers.mjs';
14
14
  import * as podState from '../../lib/pod/state.mjs';
15
15
  import { PodStore } from '../../lib/core/store.mjs';
16
- import { HttpStorage } from '../../lib/core/storage.mjs';
17
16
  import { Publisher } from '../../lib/core/publisher/index.mjs';
18
17
  import { Intake } from '../../lib/core/intake/index.mjs';
19
18
  import { C2S } from '../../lib/client/c2s.mjs';
@@ -34,7 +33,8 @@ import { AcctFeed } from '../../lib/connections/acctfeed.mjs';
34
33
  import { followActor, unfollowActor, resolveHandle } from '../../lib/core/social.mjs';
35
34
  import { podBaseOfWebId } from '../../lib/pod/urls.mjs';
36
35
  import { ImportWorker } from '../../lib/connections/import.mjs';
37
- import { openCopy, copyStorage, copyLeaseOf, moveIntoCopy, leaveCopy, renewCopyToken, standDown, ensureCopyLease, handOverCopy, forgetFailedOpen } from './copy-mode.mjs';
36
+ import { openCopy, copyStorage, copyLeaseOf, moveIntoCopy, leaveCopy, renewCopyToken, standDown, ensureCopyLease, handOverCopy, forgetFailedOpen,
37
+ podStorageOf, fencePod, followHold } from './copy-mode.mjs';
38
38
 
39
39
  // The authorities this identity answers on: exactly one, this origin. The Node
40
40
  // agent gets this from lib/guard.mjs, which is not in the browser bundle and
@@ -129,13 +129,19 @@ export class BrowserAgent {
129
129
 
130
130
  configured() { return !!this.store?.getConfig(); }
131
131
 
132
+ // Whether the gateway acts on this account's state too: its copy there, or
133
+ // with the gateway's hold off, its pod (copy-mode.mjs). An action here then
134
+ // makes sure of the lease first (requestTakeover).
135
+ get sharesState() { return !!(this.copy || this._fenced); }
136
+
132
137
  // Asked by every write / destructive path before it acts. If we already hold
133
138
  // the lease, proceed. If we are a viewer, the owner acting HERE outranks the
134
139
  // idle active device: claim the lease outright and become active.
135
140
  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);
141
+ // Working from the copy, or on the pod with the hold off, an app at the
142
+ // gateway may have taken the lease since this browser last looked: asked
143
+ // now, before acting (copy-mode.mjs).
144
+ if (!this.viewer && this.sharesState) await ensureCopyLease(this);
139
145
  if (!this.viewer) return true;
140
146
  if (!(await this.lease.takeover())) return false;
141
147
  clearTimeout(this._viewerTimer); this._viewerTimer = null;
@@ -182,6 +188,7 @@ export class BrowserAgent {
182
188
  const said = await this.tellGateway('keeper', { handle: this.doorKey, on: false });
183
189
  if (said?.status !== 200) return said || { status: 502 };
184
190
  this._keeper.kept = false;
191
+ fencePod(this);
185
192
  if (this.masto) this.masto.scheduling = false;
186
193
  }
187
194
  this.remote.keepers = on ? [webId] : [];
@@ -194,6 +201,7 @@ export class BrowserAgent {
194
201
  // A copy that could not be had while the account was not kept may be now.
195
202
  await forgetFailedOpen(this, this.frontOrigin);
196
203
  this._keeper.kept = true;
204
+ fencePod(this);
197
205
  if (this.masto) this.masto.scheduling = true;
198
206
  this.log('the gateway keeps this account running while the app is closed');
199
207
  }
@@ -211,6 +219,10 @@ export class BrowserAgent {
211
219
  async hereAtGateway() {
212
220
  const said = await this.tellGateway('here', { handle: this.doorKey, nextAt: this.store ? nextDue(this.store) : null });
213
221
  if (said?.status === 200) saveMeta(this.webId, { hereAt: Date.now() }).catch(() => {});
222
+ // The gateway's admin may have turned the hold off or on (copy-mode.mjs).
223
+ if (said?.status === 200 && typeof said.hold === 'boolean') {
224
+ await followHold(this, this.frontOrigin, said.hold).catch((e) => this.log(`the gateway's hold: ${e.message}`));
225
+ }
214
226
  return said;
215
227
  }
216
228
 
@@ -244,9 +256,10 @@ export class BrowserAgent {
244
256
  // Reading here is being here: the gateway hears so once an hour.
245
257
  clearInterval(this._openTimer);
246
258
  this._openTimer = setInterval(() => { this.checkInAtGateway(); }, BrowserAgent.OPEN_EVERY_MS);
247
- // Working from the copy, an app at the gateway takes the lease only while
248
- // it acts, and this browser takes it back (copy-mode.mjs).
249
- this.lease.onLost = () => (this.copy ? standDown(this) : this.demote());
259
+ // Working from the copy, or on the pod with the hold off, an app at the
260
+ // gateway takes the lease only while it acts, and this browser takes it
261
+ // back (copy-mode.mjs).
262
+ this.lease.onLost = () => (this.sharesState ? standDown(this) : this.demote());
250
263
  this.lease.startRenewal();
251
264
  try {
252
265
  // Forced, not revalidated, when this device WATCHED first: the active
@@ -441,7 +454,8 @@ export class BrowserAgent {
441
454
  // the deletion deny-list that every other write on this agent observes.
442
455
  // The Node agent has always passed remote.fetch here.
443
456
  const podFetch = (u, i) => this.remote.fetch(u, i);
444
- const podStateStorage = new HttpStorage(this.urls.state, podFetch);
457
+ // Fenced once the gateway is known to act on the pod too (fencePod below).
458
+ const podStateStorage = podStorageOf(this);
445
459
  this.store = new PodStore({ storage: this.copy ? copyStorage(this, podStateStorage) : podStateStorage, log: this.log });
446
460
  keepInStep(this.store, webId, this.log);
447
461
  // Single-active-agent lease (lib/lease.mjs): the inbox drain is a
@@ -545,8 +559,11 @@ export class BrowserAgent {
545
559
  // The gateway's own pod identity, when it can act for this account while
546
560
  // the app is closed (lib/gateway/keeper.mjs). Unless the owner turned that
547
561
  // off, every rule this agent writes names it beside the owner.
548
- this._keeper = standing?.keeper ? { webId: standing.keeper, kept: !!standing.kept, keptBy: standing.keptBy || null } : null;
562
+ // `hold`: whether it keeps a copy of the account at all (copy-mode.mjs).
563
+ this._keeper = standing?.keeper
564
+ ? { webId: standing.keeper, kept: !!standing.kept, keptBy: standing.keptBy || null, hold: standing.hold !== false } : null;
549
565
  if (this._keeper && !this.store.getConfig()?.keeperOff) this.remote.keepers = [this._keeper.webId];
566
+ fencePod(this);
550
567
 
551
568
  this.publisher = new Publisher({
552
569
  config: this.store.getConfig(), remote: this.remote, store: this.store,
@@ -4,9 +4,16 @@
4
4
  //
5
5
  // The state documents are read and written at the gateway instead of on the
6
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';
7
+ // the pod (the key, connected-account passwords) is still read there. Every
8
+ // fifteen minutes the gateway writes the copy to the pod and deletes it, and
9
+ // makes it again from the pod when this browser next reads or writes (the
10
+ // hold); the copy's lease stays, so nothing here notices.
11
+ //
12
+ // With the hold off, the gateway keeps no copy, and this browser works on the
13
+ // pod while the gateway acts there too for an app. The pod's lease decides
14
+ // which acts, and every write first checks this browser still holds it
15
+ // (fencePod), as the copy checks every write.
16
+ import { HttpStorage, StateApiStorage, FencedStorage, podOnly } from '../../lib/core/storage.mjs';
10
17
  import { Lease } from '../../lib/core/lease.mjs';
11
18
  import { kvGet, kvPut, kvDel } from './idb-kv.mjs';
12
19
 
@@ -76,6 +83,8 @@ export function copyStorage(agent, podState) {
76
83
  // Refused: another agent (an app at the gateway, another browser) took
77
84
  // the lease. This one stops acting at once rather than at its next renewal.
78
85
  onRefused: () => standDown(agent),
86
+ // No copy any more, and none to be made: the hold turned off.
87
+ onGone: () => { backToPod(agent).catch((e) => agent.log(`back to the pod: ${e.message}`)); },
79
88
  });
80
89
  Object.defineProperty(s, 'token', { get: () => agent.copy.token, set() {} });
81
90
  return s;
@@ -85,12 +94,44 @@ export function copyLeaseOf(agent) {
85
94
  return new Lease({ url: `${agent.copy.base}lease.json`, fetchImpl: tokenFetch(agent), log: agent.log, id: agent.holderId });
86
95
  }
87
96
 
97
+ const podFetchOf = (agent) => (u, i) => agent.remote.fetch(u, i);
98
+
99
+ /** The account's state on the pod, fenced when the gateway may act there too. */
100
+ export function podStorageOf(agent) {
101
+ const s = new FencedStorage(new HttpStorage(agent.urls.state, podFetchOf(agent)), { onRefused: () => standDown(agent) });
102
+ if (agent._fenced) s.fence = () => stillMine(agent);
103
+ return s;
104
+ }
105
+
106
+ // Whether this browser still holds the pod's lease, asked before a write.
107
+ // Unreadable is a yes: the write goes, as it would have without the check.
108
+ async function stillMine(agent) {
109
+ const cur = await agent.lease.readFresh().catch(() => null);
110
+ if (!cur || typeof cur !== 'object') return true;
111
+ return cur.holder === agent.lease.id && Date.now() < cur.expiresAt;
112
+ }
113
+
114
+ /**
115
+ * Whether this browser's writes to the pod are fenced: the account kept, with
116
+ * the hold off, and no copy. Set on the store's storage as it is now.
117
+ */
118
+ export function fencePod(agent) {
119
+ agent._fenced = !!(agent._keeper?.kept && agent._keeper.hold === false && !agent.copy && !agent.store?.getConfig()?.keeperOff);
120
+ const s = agent.store?.storage;
121
+ if (s instanceof FencedStorage) s.fence = agent._fenced ? () => stillMine(agent) : null;
122
+ }
123
+
124
+ // A lease held by the gateway, which gives it back when its piece of work is
125
+ // done: the copy's ('gateway', copy.mjs), or with the hold off the pod's
126
+ // (lib/gateway/account-agent.mjs: keeper:<its WebID>).
127
+ const byGateway = (holder) => holder === 'gateway' || String(holder || '').startsWith('keeper:');
128
+
88
129
  // Swap the lease the agent and its drain go by.
89
130
  function useLease(agent, lease) {
90
131
  agent.lease.stopRenewal();
91
132
  agent.lease = lease;
92
133
  if (agent.intake) agent.intake.lease = lease;
93
- lease.onLost = () => agent.demote();
134
+ lease.onLost = () => (agent.copy || agent._fenced ? standDown(agent) : agent.demote());
94
135
  }
95
136
 
96
137
  /**
@@ -137,8 +178,8 @@ export function standDown(agent) {
137
178
  await new Promise((r) => setTimeout(r, wait));
138
179
  if (!agent.viewer) return;
139
180
  const cur = await agent.lease.readFresh().catch(() => null);
140
- // Held by anyone but the gateway (copy.mjs: GATEWAY_HOLDER) is another browser's.
141
- if (cur && typeof cur === 'object' && cur.holder !== 'gateway' && Date.now() < cur.expiresAt) return;
181
+ // Held by anyone but the gateway is another browser's.
182
+ if (cur && typeof cur === 'object' && !byGateway(cur.holder) && Date.now() < cur.expiresAt) return;
142
183
  if (await agent.lease.acquire()) {
143
184
  clearTimeout(agent._viewerTimer); agent._viewerTimer = null;
144
185
  // Read what the app wrote, then carry on as a restart moments later
@@ -195,16 +236,39 @@ export async function leaveCopy(agent) {
195
236
  return { ok: false, why: `the gateway could not write the copy to the pod (${res.status || res.e?.message})` };
196
237
  }
197
238
  agent.copy = null;
198
- const podFetch = (u, i) => agent.remote.fetch(u, i);
199
- const lease = new Lease({ url: `${agent.urls.state}lease.json`, fetchImpl: podFetch, log: agent.log, id: agent.holderId });
200
- agent.store.attach(new HttpStorage(agent.urls.state, podFetch));
201
- await agent.store.load({ force: true });
239
+ const lease = new Lease({ url: `${agent.urls.state}lease.json`, fetchImpl: podFetchOf(agent), log: agent.log, id: agent.holderId });
240
+ agent.store.attach(podStorageOf(agent));
202
241
  useLease(agent, lease);
242
+ fencePod(agent);
243
+ await agent.store.load({ force: true });
203
244
  if (await lease.acquire()) lease.startRenewal(); else agent.demote();
204
245
  agent.log('working from the pod again');
205
246
  return { ok: true };
206
247
  }
207
248
 
249
+ /**
250
+ * The gateway keeps no copy of this account any more and will make none (its
251
+ * admin turned the hold off): back to the pod, where the gateway wrote the
252
+ * copy. Writes not yet sent are dropped, as when another agent acts, and the
253
+ * state is read afresh from the pod.
254
+ */
255
+ export async function backToPod(agent) {
256
+ if (!agent.copy || agent._backToPod) return;
257
+ agent._backToPod = true;
258
+ try {
259
+ await agent.store.discardPending();
260
+ agent.lease.stopRenewal();
261
+ agent.copy = null;
262
+ const lease = new Lease({ url: `${agent.urls.state}lease.json`, fetchImpl: podFetchOf(agent), log: agent.log, id: agent.holderId });
263
+ agent.store.attach(podStorageOf(agent));
264
+ useLease(agent, lease);
265
+ fencePod(agent);
266
+ await agent.store.load({ force: true });
267
+ if (await lease.acquire()) lease.startRenewal(); else agent.demote();
268
+ agent.log('the gateway keeps no copy of this account now: working from the pod');
269
+ } finally { agent._backToPod = false; }
270
+ }
271
+
208
272
  /**
209
273
  * The gateway has a new identity, and this account's copy was kept under the
210
274
  * old one, which the new one cannot write back for (the owner's rules name the
@@ -218,8 +282,8 @@ export async function leaveCopy(agent) {
218
282
  export async function handOverCopy(agent) {
219
283
  if (!agent.copy) return { ok: true };
220
284
  await agent.store.commit();
221
- const podFetch = (u, i) => agent.remote.fetch(u, i);
222
- const pod = new HttpStorage(agent.urls.state, podFetch);
285
+ const podFetch = podFetchOf(agent);
286
+ const pod = podStorageOf(agent);
223
287
  const docs = agent.store.names().filter((n) => !podOnly(n)).map((n) => [n, agent.store.read(n, null)]).filter(([, v]) => v !== null);
224
288
  for (const [name, value] of docs) {
225
289
  const w = await pod.write(name, JSON.stringify(value, null, 2) + '\n', 'application/json');
@@ -232,13 +296,34 @@ export async function handOverCopy(agent) {
232
296
  if (res.status !== 200) return { ok: false, why: `the gateway would not let the copy go (${res.status || res.e?.message})` };
233
297
  agent.copy = null;
234
298
  agent.store.attach(pod);
235
- await agent.store.load({ force: true });
236
299
  useLease(agent, podLease);
300
+ fencePod(agent);
301
+ await agent.store.load({ force: true });
237
302
  podLease.startRenewal();
238
303
  agent.log(`handed the account's copy over: ${docs.length} documents written to the pod`);
239
304
  return { ok: true };
240
305
  }
241
306
 
307
+ /**
308
+ * The gateway's admin turned the hold off or on since this browser last
309
+ * heard (agent.mjs: hereAtGateway): off, the copy goes back to the pod and
310
+ * writes there are fenced; on, the account moves onto a copy again.
311
+ */
312
+ export async function followHold(agent, frontOrigin, hold) {
313
+ if (!agent._keeper) return;
314
+ const was = agent._keeper.hold;
315
+ agent._keeper.hold = hold;
316
+ // Asked again at every check-in until the copy has gone.
317
+ if (!hold && agent.copy) {
318
+ const left = await leaveCopy(agent);
319
+ if (!left.ok) agent.log(`the gateway keeps no copies now; leaving this one: ${left.why}`);
320
+ } else if (hold && was === false && !agent.copy && agent._keeper.kept && !agent.viewer && !agent.store.getConfig()?.keeperOff) {
321
+ await forgetFailedOpen(agent, frontOrigin);
322
+ await moveIntoCopy(agent, frontOrigin);
323
+ }
324
+ fencePod(agent);
325
+ }
326
+
242
327
  /** A token near its end, renewed; the hourly check-in asks. */
243
328
  export async function renewCopyToken(agent, frontOrigin) {
244
329
  if (!agent.copy || agent.copy.expiresAt - Date.now() > RENEW_BEFORE_MS) return;
@@ -32576,6 +32576,9 @@ function apUrls(remotePod, root, { publicBase = null } = {}) {
32576
32576
  // reads them there, with a credential the pod checks.
32577
32577
  ownOutbox: home + "ap/private/outbox",
32578
32578
  liked: home + "ap/private/liked",
32579
+ // The inbox as its owner reads it (§5.2): what the account received,
32580
+ // kept by the drain as a paged collection beside the archive.
32581
+ ownInbox: home + "ap/private/inbox",
32579
32582
  profileHtml: face + "ap/profile.html",
32580
32583
  // Media stays on the pod even when fronted: attachment urls are not
32581
32584
  // identity-checked by remotes, and proxying blobs would be pure cost.
@@ -34144,12 +34147,13 @@ async function attachRows({ fetch: f, front, config, plain }, { log: log2 = () =
34144
34147
  }
34145
34148
  return { gateway: { front: origin, mode: "trust", secrets }, handles: rows.map((r) => r.handle) };
34146
34149
  }
34147
- async function keepRows({ fetch: f, front, config, on = true }) {
34150
+ async function keepRows({ fetch: f, front, config, on = true, between = null }) {
34148
34151
  const origin = String(front).replace(/\/+$/u, "");
34149
34152
  const forumHandle = config.handle;
34150
34153
  const handles = [...(config.categories || []).map((c) => c.slug), forumHandle];
34151
34154
  let keeper = null;
34152
34155
  for (const handle of handles) {
34156
+ if (handle === forumHandle && between) await between(keeper);
34153
34157
  const res = await f(`${origin}/api/keeper`, {
34154
34158
  method: "POST",
34155
34159
  headers: { "content-type": "application/json" },
@@ -34184,8 +34188,13 @@ async function setUpForumAtGateway({ remote, storageFor, fetch: f, front, ownerW
34184
34188
  await writeConfig2(storage, { ...config, gateway, republish: true });
34185
34189
  onStep("attach", "ok");
34186
34190
  onStep("keep", "running");
34187
- const { keeper } = await keepRows({ fetch: f, front, config, on: true });
34188
- await nameKeeperInRules(remote, { config, plain, keeper, ownerWebId });
34191
+ const { keeper } = await keepRows({
34192
+ fetch: f,
34193
+ front,
34194
+ config,
34195
+ on: true,
34196
+ between: (k) => nameKeeperInRules(remote, { config, plain, keeper: k, ownerWebId })
34197
+ });
34189
34198
  onStep("keep", "ok");
34190
34199
  return { handle: config.handle, front: gateway.front, handles, keeper };
34191
34200
  }