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.
- package/README.md +29 -8
- package/architecture.md +8 -0
- package/bin/fedipod.mjs +6 -0
- package/cli.md +7 -1
- package/device-agent.md +3 -3
- package/gateway.md +69 -8
- package/groups.md +2 -1
- package/gui.md +3 -1
- package/lib/client/c2s.mjs +97 -43
- package/lib/client/masto/accounts.mjs +1 -0
- package/lib/client/masto/bridge.mjs +61 -0
- package/lib/client/masto/index.mjs +4 -1
- package/lib/client/masto/oauth.mjs +1 -1
- package/lib/client/masto/statuses.mjs +3 -0
- package/lib/connections/acctfeed.mjs +13 -9
- package/lib/connections/bskyfeed.mjs +13 -9
- package/lib/connections/tagfeed.mjs +14 -9
- package/lib/core/deliver.mjs +17 -1
- package/lib/core/intake/activities.mjs +18 -2
- package/lib/core/intake/group.mjs +3 -1
- package/lib/core/intake/index.mjs +226 -48
- package/lib/core/intake/notes.mjs +12 -5
- package/lib/core/intake/verify.mjs +11 -0
- package/lib/core/lease.mjs +15 -1
- package/lib/core/place.mjs +82 -0
- package/lib/core/pod-only.mjs +4 -0
- package/lib/core/publisher/collections.mjs +37 -2
- package/lib/core/publisher/index.mjs +25 -1
- package/lib/core/publisher/notes.mjs +73 -7
- package/lib/core/publisher/own.mjs +143 -0
- package/lib/core/publisher/questions.mjs +5 -3
- package/lib/core/scheduled.mjs +39 -0
- package/lib/core/social.mjs +62 -32
- package/lib/core/storage.mjs +67 -0
- package/lib/core/store.mjs +77 -5
- package/lib/core/wire.mjs +51 -15
- package/lib/device/admin/routes/lifecycle.mjs +1 -1
- package/lib/device/admin/routes/setup.mjs +17 -1
- package/lib/device/cli/commands/setup.mjs +50 -8
- package/lib/device/cli/context.mjs +1 -1
- package/lib/device/migrate.mjs +1 -1
- package/lib/device/setup.mjs +45 -8
- package/lib/gateway/account-agent.mjs +111 -0
- package/lib/gateway/copy.mjs +381 -0
- package/lib/gateway/front-core.mjs +74 -63
- package/lib/gateway/gateway-core.mjs +107 -9
- package/lib/gateway/held-mail.mjs +198 -0
- package/lib/gateway/keeper-due.mjs +39 -0
- package/lib/gateway/keeper-session.mjs +10 -0
- package/lib/gateway/keeper.mjs +72 -0
- package/lib/gateway/masto-gateway.mjs +510 -0
- package/lib/gateway/quiet.mjs +7 -2
- package/lib/gateway/relay-extras.mjs +89 -0
- package/lib/gateway/state-api.mjs +207 -0
- package/lib/gateway/token-claims.mjs +16 -0
- package/lib/pod/containers.mjs +17 -0
- package/lib/pod/location.mjs +52 -0
- package/lib/pod/notes.mjs +2 -4
- package/lib/pod/transport.mjs +214 -24
- package/lib/pod/type-index.mjs +101 -0
- package/lib/pod/urls.mjs +6 -0
- package/lib/server/embed.mjs +7 -6
- package/lib/session/README.md +5 -5
- package/lib/session/demo.html +1 -1
- package/lib/session/fedi-account.mjs +19 -10
- package/lib/session/package.json +2 -2
- package/package.json +2 -2
- package/run-agent.mjs +6 -16
- package/scripts/stage-site.mjs +17 -4
- package/web/admin/actors.js +2 -0
- package/web/admin/gateway.js +14 -1
- package/web/admin/index.html +13 -0
- package/web/admin/oauth-signin.mjs +1 -1
- package/web/admin/record.js +4 -1
- package/web/admin/setup/index.html +17 -1
- package/web/admin/setup/setup.js +18 -5
- package/web/app/README.md +2 -2
- package/web/app/admin-facade.mjs +12 -2
- package/web/app/agent.mjs +228 -51
- package/web/app/boot.mjs +75 -32
- package/web/app/copy-mode.mjs +223 -0
- package/web/app/dist/boot.js +498 -88
- package/web/app/dist/boot.js.map +4 -4
- package/web/app/dist/sw.js +4016 -2539
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/index.html +16 -0
- package/web/app/signup.mjs +65 -26
- package/web/app/sw-src.mjs +15 -53
- package/web/app/update.js +3 -2
- package/web/app/warm-start.mjs +115 -0
- package/web/app-signin/app-signin.mjs +78 -0
- package/web/app-signin/index.html +41 -0
- package/web/front/run.html +7 -1
- package/web/front/run.js +30 -4
package/web/app/admin-facade.mjs
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
169
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
359
|
-
//
|
|
360
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
409
|
-
//
|
|
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
|
-
//
|
|
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
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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(); }
|