fedipod 1.57.2 → 1.57.5

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/gateway.md CHANGED
@@ -192,7 +192,8 @@ browser, the gateway and any app you use all work from one copy of your
192
192
  timeline, notifications, followers, settings and the rest of your FediPod
193
193
  state, made at fedipod.net from your pod, so they never disagree. Every
194
194
  fifteen minutes, whatever is happening, fedipod.net writes that copy to your
195
- pod and deletes it. The next time anything needs your account (FediPod, an
195
+ pod and deletes it. If your pod will not take it, the copy stays at
196
+ fedipod.net and is tried again an hour later. The next time anything needs your account (FediPod, an
196
197
  app, or the gateway's own work while you're away), the copy is made again from
197
198
  your pod. You don't notice, except that an app's first check after a deletion
198
199
  can take a few seconds longer. When your address moves or is closed, the copy
@@ -206,7 +207,8 @@ with one setting, `FEDIPOD_HOLD` ([netlify/README.md](netlify/README.md)).
206
207
 
207
208
  - **On**, the default and fedipod.net's choice: as above. Mail waits at the
208
209
  gateway for up to fifteen minutes, and each account's copy is written to its
209
- pod and deleted every fifteen minutes.
210
+ pod and deleted every fifteen minutes. A pod that refuses its copy is asked
211
+ again an hour later.
210
212
  - **Off**: the gateway keeps no copy of anyone's account and holds no mail.
211
213
  Mail goes straight to the pod as it arrives, an app's every check reads the
212
214
  pod, and FediPod in the browser works on the pod, taking turns with the
@@ -19,9 +19,11 @@ export function isModerationAsk(intake, activity) {
19
19
  if (activity.type === 'Delete') {
20
20
  const id = typeof activity.object === 'string' ? activity.object : activity.object?.id;
21
21
  const s = id && intake.store.getStatuses().find(x => x.noteId === id);
22
- // Only a post the group holds and did not author — removing those is
23
- // moderation; everything else is the author's own Delete.
24
- return !!s && s.kind !== 'post';
22
+ const by = typeof activity.actor === 'string' ? activity.actor : activity.actor?.id;
23
+ // Only a post the group holds, written by neither the group nor whoever
24
+ // is deleting it: removing those is moderation. Everything else is the
25
+ // author's own Delete, a moderator's of their own post included.
26
+ return !!s && s.kind !== 'post' && s.actor !== by;
25
27
  }
26
28
  if (activity.type === 'Add' || activity.type === 'Remove') {
27
29
  const target = typeof activity.target === 'string' ? activity.target : activity.target?.id;
@@ -370,6 +370,9 @@ export class Intake {
370
370
  }
371
371
 
372
372
  async _drainOnce() {
373
+ // Whether this sweep took mail in and left more in the inbox: a caller that
374
+ // runs one sweep and stops (a forum kept by a gateway) asks to come back.
375
+ this.moreWaiting = false;
373
376
  this._sweepGen = this.store.generation;
374
377
  const cooling = this.drainCooldownUntil - Date.now();
375
378
  if (cooling > 0) {
@@ -452,9 +455,10 @@ export class Intake {
452
455
  };
453
456
 
454
457
  let spent = 0; // deliveries taken this sweep, a batch counting each of its own
458
+ let cut = false; // stopped at the cap with the listing not finished
455
459
  for (const { url, size, receipt: hasReceipt } of items) {
456
460
  if (url.endsWith('.keep')) continue;
457
- if (spent >= MAX_ITEMS_PER_DRAIN) { this._drainAgain = handled > 0 || pending.length > 0; break; }
461
+ if (spent >= MAX_ITEMS_PER_DRAIN) { cut = this._drainAgain = handled > 0 || pending.length > 0; break; }
458
462
  if (isBatch(url)) {
459
463
  try {
460
464
  const took = await this._takeBatch(url);
@@ -531,7 +535,9 @@ export class Intake {
531
535
  // Made progress and there is more waiting: go straight round rather than
532
536
  // sleeping. Gated on progress so a sweep that achieved nothing — a
533
537
  // cooldown, an unwritable store, poison at the head — cannot spin.
534
- if (handled > 0 && all.length > items.length && !this.stopped) this._drainAgain = true;
538
+ const more = handled > 0 && all.length > items.length;
539
+ this.moreWaiting = more || cut;
540
+ if (more && !this.stopped) this._drainAgain = true;
535
541
  }
536
542
 
537
543
  // One delivery, alone in the inbox or in a batch: read as JSON-LD, checked
@@ -258,8 +258,10 @@ export class CopyStorage {
258
258
  * Held for `ms` at a time and extended while its holder runs, so a long run
259
259
  * keeps it and a holder that died loses it within `ms`.
260
260
  */
261
- export async function lockCopy(kv, h, { ms = 60_000, waitMs = 8_000, by = crypto.randomUUID() } = {}) {
262
- const key = `${account(h)}/lock`;
261
+ export const lockCopy = (kv, h, opts) => lockKey(kv, `${account(h)}/lock`, opts);
262
+
263
+ /** The same lock under any key: the round holds one, so two rounds never run at once. */
264
+ export async function lockKey(kv, key, { ms = 60_000, waitMs = 8_000, by = crypto.randomUUID() } = {}) {
263
265
  const give = (etag) => {
264
266
  let tag = etag;
265
267
  const timer = setInterval(async () => {
@@ -488,6 +490,25 @@ export async function holdOver(kv, h, { pod, podFetch, stateUrl, log = () => {}
488
490
  } finally { await kv.delete(`${h}/closing`).catch(() => {}); }
489
491
  }
490
492
 
493
+ /**
494
+ * A pod that would not take an account's copy is left alone for COPY_RETRY_MS
495
+ * before the round asks it again (flush-mail.mjs): asked every round, a pod
496
+ * that goes on refusing took a quarter of the round. With no wait (`waitMs`
497
+ * 0), every round asks. `refusedAt` answers when the pod last refused, or 0
498
+ * when the account is to be tried; `afterTry` records how the try went.
499
+ */
500
+ export const COPY_RETRY_MS = 3_600_000;
501
+ export async function refusedAt(kv, h, { waitMs = COPY_RETRY_MS, now = Date.now() } = {}) {
502
+ if (!waitMs) return 0;
503
+ const at = (await readJson(kv, `${account(h)}/refused`)).doc?.at || 0;
504
+ return at && now - at < waitMs ? at : 0;
505
+ }
506
+ export async function afterTry(kv, h, ok, { waitMs = COPY_RETRY_MS, now = Date.now() } = {}) {
507
+ if (!waitMs) return;
508
+ if (!ok) await kv.set(`${account(h)}/refused`, JSON.stringify({ at: now }));
509
+ else if (await kv.get(`${h}/refused`)) await kv.delete(`${h}/refused`);
510
+ }
511
+
491
512
  /** A copy listed with no meta is one whose delete was cut off: its documents go. */
492
513
  export async function clearLeftover(kv, h, { log = () => {} } = {}) {
493
514
  if (await copyMeta(kv, h)) return false;
@@ -23,7 +23,7 @@ import * as podRoot from '../pod/root.mjs';
23
23
  import * as podPolicy from '../pod/policy.mjs';
24
24
  import { podBaseOfWebId } from '../pod/urls.mjs';
25
25
  import { routeQuietApi, noteOpened, noteReceived, closedState, accountState, closedAnswer, provedOwner } from './quiet.mjs';
26
- import { holdsMail, mayBeAway, isPresent, holdingPut, afterHeld, waitsForRound, routeHereApi, routeKeeperApi, routePushApi, routeAlertApi } from './held-mail.mjs';
26
+ import { holdsMail, mayBeAway, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi, routePushApi, routeAlertApi } from './held-mail.mjs';
27
27
  import { routeCheckApi, missingLetter } from './check-letter.mjs';
28
28
  import { routeNoticesApi } from './notices.mjs';
29
29
  import { routeForumPage } from './forum-pages.mjs';
@@ -827,17 +827,17 @@ async function route(request, ctx) {
827
827
  const standing = await accountState(ctx, up.handle, rec);
828
828
  // A browser account's mail waits here while its app is closed (held-mail.mjs), unless the hold is off.
829
829
  const away = mayBeAway(ctx, rec) && !(await isPresent(ctx, up.handle)); const held = away && holdsMail(ctx, rec);
830
- const { status, reason, content, bytes, type, accountDeleted, notifies, name, raw, receipt } = await handleDelivery(request, identFor(rec, policy),
830
+ const keptForum = !!(ctx.forum?.isForumRow(rec) && rec.keeper); // it acts on no notice that an account was deleted: its door keeps none
831
+ const { status, reason, content, bytes, type, notifies, name, raw, receipt } = await handleDelivery(request, identFor(rec, policy),
831
832
  { podPut: held ? holdingPut(ctx, up.handle, rec) : (u, b, ct) => ctx.podPut(up.handle, u, b, ct),
832
- fetchImpl: ctx.fetchImpl, paused: standing.paused, origin: ctx.frontOrigin || null });
833
+ fetchImpl: ctx.fetchImpl, paused: standing.paused, origin: ctx.frontOrigin || null, dropAccountDeleted: keptForum });
833
834
  // One line per delivery, so "did it arrive at the door" has an answer.
834
835
  console.log(`door @${up.handle}: delivery → ${status} (${reason}${held && status === 202 ? ', held' : ''})`);
835
836
  if (status === 202 && content) await noteReceived(ctx, up.handle, rec, bytes);
836
837
  if (away && status === 202) await afterHeld(ctx, up.handle, rec, { type, notifies });
837
- // A forum fedipod.net keeps: the delivery is placed now, in this request,
838
- // and the run that carries it is started. Whatever could not be placed
839
- // here is still in the inbox for that run. A notice that an account was deleted waits for the round's run (held-mail.mjs).
840
- if (status === 202 && name && ctx.forum?.isForumRow(rec) && rec.keeper && !await waitsForRound(ctx, up.handle, rec, { accountDeleted })) {
838
+ // The delivery is placed now, in this request, and the run that carries it
839
+ // is started. Whatever could not be placed is still in the inbox for that run.
840
+ if (status === 202 && name && keptForum) {
841
841
  const placed = await ctx.forum.placeAtDoor(ctx, up.handle, rec, { name, raw, receipt })
842
842
  .catch((e) => ({ placed: false, why: e.message }));
843
843
  console.log(`door @${up.handle}: ${placed.placed ? 'placed' : `not placed (${placed.why})`}`);
@@ -115,7 +115,9 @@ export function asSigned(request, bytes, origin) {
115
115
  // `admit(verified)`, when given, says whether the inbox has room for this
116
116
  // delivery now; one without room is answered 503 and written nowhere, so its
117
117
  // sender tries again later. Without it every delivery that passes is written.
118
- export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES, paused = false, origin = null, admit = null } = {}) {
118
+ // `dropAccountDeleted`: a sender's notice that its own account is gone is
119
+ // answered and written nowhere, for an inbox whose reader acts on none.
120
+ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES, paused = false, origin = null, admit = null, dropAccountDeleted = false } = {}) {
119
121
  // The body, once, as the bytes the sender digested; the text of it is what
120
122
  // is filed and hashed, as before.
121
123
  let bytes, raw;
@@ -131,6 +133,10 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
131
133
  // Edge drops — silent 202 so a rejected sender does not retry a delivery we
132
134
  // will never accept. None of these becomes a pod write.
133
135
  if (isBlocked(actor, ident.blocklist)) return { status: 202, reason: 'blocked' };
136
+ // Mastodon sends one of these to every inbox it knows, for every account it deletes.
137
+ if (dropAccountDeleted && activity.type === 'Delete' && idOf(activity.object) === actor) {
138
+ return { status: 202, reason: "an account's deletion, not kept" };
139
+ }
134
140
  const content = !CONTROL.has(activity.type);
135
141
  if (content && paused) return { status: 202, reason: 'paused' };
136
142
  if (content && !concernsUsAtEdge(activity, ident)) {
@@ -180,11 +186,8 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
180
186
  // that is what preserves the pod's buffer property without the gateway
181
187
  // holding any state.
182
188
  if (!okA) return { status: 502, reason: 'pod inbox write failed' };
183
- // `accountDeleted`: the sender says its own account is gone. Mastodon sends
184
- // one of these to every inbox it knows, for every account it deletes.
185
189
  return { status: 202, reason: v.verified ? 'verified' : failed ? `buffered-unverified: ${failed}` : 'buffered-unverified',
186
- content, bytes: bytes.byteLength, type: activity.type, accountDeleted: activity.type === 'Delete' && idOf(activity.object) === actor,
187
- notifies: notifies(activity, ident), name: hash, raw, receipt };
190
+ content, bytes: bytes.byteLength, type: activity.type, notifies: notifies(activity, ident), name: hash, raw, receipt };
188
191
  }
189
192
 
190
193
  // The outbox door: the owner's own post, taken on their behalf.
@@ -133,18 +133,6 @@ export async function wakesKeeper(ctx, handle, rec, type) {
133
133
  return true;
134
134
  }
135
135
 
136
- // A notice that an account was deleted, arriving for a forum this gateway
137
- // keeps, starts no run of its own: the forum is marked as having work, and the
138
- // round's run reads the notice from the inbox. False where that cannot be
139
- // noted, so the caller starts the run as for any delivery.
140
- export async function waitsForRound(ctx, handle, rec, { accountDeleted = false } = {}) {
141
- const forum = rec.kind === 'application' ? handle : rec.forum;
142
- if (!accountDeleted || !forum || !ctx.noteNext) return false;
143
- const noted = await ctx.noteNext(forum, new Date().toISOString(), { earliest: true }).then(() => true, () => false);
144
- if (noted) console.log(`door @${handle}: an account's deletion, left for the round's run of @${forum}`);
145
- return noted;
146
- }
147
-
148
136
  // After a delivery is held: a follow wakes the keeper, and anything that
149
137
  // becomes a notification is read in and pushed at once to the phones and
150
138
  // browsers the owner signed up for notifications (masto-gateway.mjs: pushHeld).
@@ -7,18 +7,30 @@
7
7
  // sets it to now, and one that arrives while a run is going is not lost when
8
8
  // the run finishes. A run that could not take the account's lease tries again
9
9
  // next round; one that failed, in an hour.
10
+ //
11
+ // A time is kept as an ISO string. A run may hand one in as a number of
12
+ // milliseconds (a forum's does), and one stored that way is read the same.
10
13
 
11
14
  const RETRY_FAILED_MS = 3_600_000;
12
15
 
13
- // The sooner of two times (ISO strings, either of them null).
14
- export const earliest = (a, b) => (!a ? b || null : !b ? a : Date.parse(a) <= Date.parse(b) ? a : b);
16
+ // A time as an ISO string, or null where there is none to read.
17
+ const iso = (t) => {
18
+ const ms = typeof t === 'number' ? t : t ? Date.parse(t) : NaN;
19
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
20
+ };
21
+
22
+ // The sooner of two times (either of them null).
23
+ export const earliest = (a, b) => {
24
+ const [x, y] = [iso(a), iso(b)];
25
+ return !x ? y : !y ? x : Date.parse(x) <= Date.parse(y) ? x : y;
26
+ };
15
27
 
16
28
  export function keeperBook(store) {
17
29
  return {
18
30
  async noteKept(handle, out, started = 0, now = Date.now()) {
19
31
  const prev = (await store.get(handle)) || {};
20
32
  const next = out?.retry === 'soon' ? prev.nextAt
21
- : out?.retry === 'later' ? new Date(now + RETRY_FAILED_MS).toISOString() : out?.nextAt;
33
+ : out?.retry === 'later' ? new Date(now + RETRY_FAILED_MS).toISOString() : iso(out?.nextAt);
22
34
  const woken = prev.noted >= started ? prev.nextAt : null;
23
35
  await store.set(handle, { at: now, waiting: out?.waiting || 0, skipped: out?.skipped || null,
24
36
  nextAt: earliest(next || null, woken) });
@@ -31,7 +43,8 @@ export function keeperBook(store) {
31
43
  const due = [];
32
44
  for (const key of await store.keys()) {
33
45
  const k = (await store.get(key)) || {};
34
- if (k.nextAt && Date.parse(k.nextAt) <= now) due.push(key);
46
+ const at = iso(k.nextAt);
47
+ if (at && Date.parse(at) <= now) due.push(key);
35
48
  }
36
49
  return due;
37
50
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "1.57.2",
3
+ "version": "1.57.5",
4
4
  "description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -76,7 +76,7 @@
76
76
  "@rdfjs/term-set": "^2.0.3",
77
77
  "@solid/access-token-verifier": "^2.1.1",
78
78
  "clownface": "^2.0.3",
79
- "fediverse-session": "^0.2.1",
79
+ "fediverse-session": "^0.2.2",
80
80
  "jose": "^6.2.8",
81
81
  "json-canon": "^1.0.1",
82
82
  "jsonld": "^9.0.0",
@@ -18,9 +18,14 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fn-bundles-'));
18
18
  let fails = 0;
19
19
  const check = (ok, msg) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${msg}`); if (!ok) fails++; };
20
20
  try {
21
- // The functions carry the forum as it is in its own folder now.
22
- const { bringForum } = await import('./forum-package.mjs');
21
+ // The functions carry the forum as it is in its own folder now, running on
22
+ // this folder's FediPod: on an older copy the forum lacked the day's fixes
23
+ // while every test, which runs it on this folder, passed (1.57.3, 2026-11-14).
24
+ const { bringForum, forumCoreDiffers } = await import('./forum-package.mjs');
23
25
  bringForum();
26
+ const behind = forumCoreDiffers();
27
+ check(behind.length === 0, `the forum runs on this folder's FediPod${behind.length
28
+ ? ` (${behind.length} file(s) under lib/ differ: ${behind.slice(0, 5).join(', ')}${behind.length > 5 ? ', …' : ''})` : ''}`);
24
29
  execFileSync('netlify', ['functions:build', '--src', path.join(root, 'netlify/functions'), '--functions', path.join(tmp, 'zips')],
25
30
  { cwd: root, stdio: 'ignore' });
26
31
  const manifest = JSON.parse(fs.readFileSync(path.join(tmp, 'zips/manifest.json'), 'utf8'));
@@ -5,22 +5,44 @@
5
5
  // publish it and put in node_modules without touching FediPod's package list,
6
6
  // so nothing FediPod records names a forum version. Staging the site, the
7
7
  // bundle check and the app build each bring it first.
8
+ //
9
+ // The forum runs on FediPod, and here that is this folder: packed the same
10
+ // way and installed beside the forum, so the forum's `fedipod` is the code
11
+ // being deployed and not the one the registry has.
8
12
  import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';
9
13
  import { execFileSync } from 'node:child_process';
14
+ import { createRequire } from 'node:module';
10
15
  import { fetchDependent, root } from './dependents.mjs';
11
16
 
12
- /** The forum from its folder, installed in node_modules. Returns its folder and version. */
17
+ /** The forum from its folder, and this folder for it to run on, installed in node_modules. Returns the forum's folder and version. */
13
18
  export function bringForum({ log = console.log } = {}) {
14
19
  const dir = fetchDependent('fedipod-bb', { log });
15
20
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'forum-pack-'));
16
21
  try {
17
- const packed = JSON.parse(execFileSync('npm', ['pack', dir, '--pack-destination', tmp, '--ignore-scripts', '--json'],
22
+ const pack = (from) => JSON.parse(execFileSync('npm', ['pack', from, '--pack-destination', tmp, '--ignore-scripts', '--json'],
18
23
  { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }))[0];
19
- execFileSync('npm', ['install', '--no-save', '--no-audit', '--no-fund', '--ignore-scripts', path.join(tmp, packed.filename)],
20
- { cwd: root, stdio: 'ignore' });
21
- log(`forum: fedipod-bb ${packed.version} from ${path.relative(root, dir) || dir}`);
22
- return { dir, version: packed.version };
24
+ const forum = pack(dir);
25
+ const self = pack(root);
26
+ execFileSync('npm', ['install', '--no-save', '--no-audit', '--no-fund', '--ignore-scripts',
27
+ path.join(tmp, forum.filename), path.join(tmp, self.filename)], { cwd: root, stdio: 'ignore' });
28
+ log(`forum: fedipod-bb ${forum.version} from ${path.relative(root, dir) || dir}, on fedipod ${self.version} from this folder`);
29
+ return { dir, version: forum.version };
23
30
  } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
24
31
  }
25
32
 
33
+ /**
34
+ * The files under lib/ that the forum's `fedipod` and this folder do not
35
+ * have the same. Empty when the forum runs on this folder's code.
36
+ */
37
+ export function forumCoreDiffers() {
38
+ const theirs = path.join(path.dirname(createRequire(path.join(root, 'node_modules/fedipod-bb/package.json')).resolve('fedipod/package.json')), 'lib');
39
+ const ours = path.join(root, 'lib');
40
+ const files = (base) => fs.readdirSync(base, { recursive: true, withFileTypes: true })
41
+ .filter((e) => e.isFile()).map((e) => path.relative(base, path.join(e.parentPath, e.name)));
42
+ const same = (rel) => {
43
+ try { return fs.readFileSync(path.join(ours, rel)).equals(fs.readFileSync(path.join(theirs, rel))); } catch { return false; }
44
+ };
45
+ return [...new Set([...files(ours), ...files(theirs)])].filter((rel) => !same(rel)).sort();
46
+ }
47
+
26
48
  if (import.meta.url === `file://${process.argv[1]}`) bringForum();
package/web/app/boot.mjs CHANGED
@@ -222,9 +222,10 @@ window.fedipodSignin = async ({ address }) => {
222
222
  };
223
223
 
224
224
  // Called on every page load. Returns 'signed-in' | 'restored' | 'anonymous' |
225
- // 'signup' | 'move-in' — the last two being a return from the pod's login
226
- // in the middle of creating an account: with no account on the pod yet, or
227
- // with one whose address lives at another gateway.
225
+ // 'signup' | 'move-in' | 'leaving' — 'signup' and 'move-in' being a return
226
+ // from the pod's login in the middle of creating an account: with no account
227
+ // on the pod yet, or with one whose address lives at another gateway.
228
+ // 'leaving': the page is on its way to the pod's login.
228
229
  window.fedipodOnLoad = async () => {
229
230
  if (new URLSearchParams(location.search).get('code')) {
230
231
  const done = await completeLogin({ currentUrl: location.href });
@@ -240,8 +241,21 @@ window.fedipodOnLoad = async () => {
240
241
  await bootWorker({ reset: true }); // this may be a different account
241
242
  return 'signed-in';
242
243
  }
243
- if (await getSession()) { await bootWorker(); return 'restored'; }
244
- return 'anonymous';
244
+ const kept = await getSession();
245
+ if (!kept) return 'anonymous';
246
+ // What the browser has stored says a sign-in was made, not that the pod
247
+ // still honours it. The pod is asked before anything is started on it, and
248
+ // only when the sign-in's time is up. One the pod has ended goes to the
249
+ // pod's own login, which the stored sign-in names: nobody is asked to press
250
+ // a button for it. A pod that cannot be asked is left to the start below.
251
+ if (await kept.check().catch(() => true) === false) {
252
+ try {
253
+ location.href = (await beginLogin({ issuer: kept.issuer, redirectUri: REDIRECT })).authorizationUrl;
254
+ return 'leaving';
255
+ } catch { return 'anonymous'; } // its login cannot be reached: the sign-in page, as for anybody
256
+ }
257
+ await bootWorker();
258
+ return 'restored';
245
259
  };
246
260
 
247
261
  window.fedipodSignOut = async () => {
@@ -366,6 +380,7 @@ if (typeof document !== 'undefined') (async () => {
366
380
  const signIn = () => { location.href = '/?add'; };
367
381
  const known = {
368
382
  'sign-in-refused': { title: 'Your pod refused this sign-in', retry: 'Sign in again', go: signIn },
383
+ 'signed-out': { title: 'Your sign-in has ended', retry: 'Sign in again', go: signIn },
369
384
  'pod-busy': { title: 'Your pod is asking for a pause', retry: 'Reload', go: reload, wait: 45 },
370
385
  'pod-error': { title: 'Your pod had an error', retry: 'Reload', go: reload, wait: 20 },
371
386
  'pod-unreachable': { title: 'Your pod could not be reached', retry: 'Reload', go: reload, wait: 20 },
@@ -440,6 +455,7 @@ if (typeof document !== 'undefined') (async () => {
440
455
  // web/admin surface as local). Its own script signs the framed client in on
441
456
  // a fresh session and forwards deep links on a returning one, so both a
442
457
  // fresh sign-in and a restored session land in the same place.
458
+ if (state === 'leaving') return;
443
459
  if (state === 'signed-in' || state === 'restored') { location.href = '/admin/client/'; return; }
444
460
  // Back from the pod's login with no account there yet, or with one to
445
461
  // move here: the identity screen, once the form below is wired.
@@ -34591,49 +34591,89 @@ function solidOidcSession({ dbName = "solid-oidc-session", clientName = "Solid a
34591
34591
  await idbDel("session");
34592
34592
  await idbDel("pending");
34593
34593
  }
34594
- function sessionHandle(s) {
34595
- let { accessToken, expiresAt, refreshToken } = s;
34596
- const jwkP = publicJwk(s.pair);
34597
- const refresh = async () => {
34598
- if (!refreshToken) throw new Error("session expired and there is no refresh token \u2014 sign in again");
34594
+ const ended = (message) => Object.assign(new Error(message), { signedOut: true });
34595
+ const stillGood = (row) => Date.now() < row.expiresAt - 3e4;
34596
+ const locks = () => globalThis.navigator?.locks;
34597
+ const oneAtATime = (fn) => locks()?.request ? locks().request(`${dbName}:renew`, fn) : fn();
34598
+ const RACE_WAIT_MS = 1500;
34599
+ function sessionHandle(first) {
34600
+ let s = first;
34601
+ let jwkP = publicJwk(s.pair);
34602
+ const host = () => new URL(s.tokenEndpoint).host;
34603
+ const takeUp = (row) => {
34604
+ s = row;
34605
+ jwkP = publicJwk(row.pair);
34606
+ };
34607
+ const reread = async () => {
34608
+ const row = await idbGet("session");
34609
+ if (!row) throw ended("signed out \u2014 sign in again");
34610
+ if (row.webId !== s.webId) throw new Error("signed in as somebody else since this session was read");
34611
+ takeUp(row);
34612
+ };
34613
+ const refresh = () => oneAtATime(async () => {
34614
+ const mine = s.accessToken;
34615
+ await reread();
34616
+ if (s.accessToken !== mine && stillGood(s)) return;
34617
+ if (!s.refreshToken) {
34618
+ await idbDel("session");
34619
+ throw ended("session expired and there is no refresh token \u2014 sign in again");
34620
+ }
34621
+ const sent = s;
34599
34622
  const jwk = await jwkP;
34600
34623
  let res;
34601
34624
  try {
34602
- res = await fetch(s.tokenEndpoint, {
34625
+ res = await fetch(sent.tokenEndpoint, {
34603
34626
  method: "POST",
34604
- headers: { "content-type": "application/x-www-form-urlencoded", dpop: await dpopProof(s.pair, jwk, "POST", s.tokenEndpoint) },
34605
- body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: s.client_id, scope: "openid webid offline_access" })
34627
+ headers: { "content-type": "application/x-www-form-urlencoded", dpop: await dpopProof(sent.pair, jwk, "POST", sent.tokenEndpoint) },
34628
+ body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: sent.refreshToken, client_id: sent.client_id, scope: "openid webid offline_access" })
34606
34629
  });
34607
34630
  } catch (e) {
34608
- throw new Error(`${e.message} \u2014 renewing your sign-in at ${new URL(s.tokenEndpoint).host}`);
34631
+ throw new Error(`${e.message} \u2014 renewing your sign-in at ${host()}`);
34609
34632
  }
34610
34633
  const tok = await res.json().catch(() => ({}));
34634
+ const now = await idbGet("session");
34635
+ if (now && now.webId !== sent.webId) throw new Error("signed in as somebody else while this session was refreshing");
34636
+ if (res.ok && tok.access_token) {
34637
+ if (!now) throw ended("signed out while this sign-in was being renewed");
34638
+ takeUp({
34639
+ ...sent,
34640
+ accessToken: tok.access_token,
34641
+ refreshToken: tok.refresh_token || sent.refreshToken,
34642
+ expiresAt: Date.now() + Math.max(30, tok.expires_in || 300) * 1e3
34643
+ });
34644
+ await idbPut("session", s);
34645
+ return;
34646
+ }
34611
34647
  if (res.status === 429) {
34612
- throw new Error(`${new URL(s.tokenEndpoint).host} is asking us to slow down \u2014 your sign-in could not be renewed just now`);
34648
+ throw new Error(`${host()} is asking us to slow down \u2014 your sign-in could not be renewed just now`);
34613
34649
  }
34614
- if (!res.ok || !tok.access_token) {
34650
+ if (res.status >= 400 && res.status < 500 && typeof tok.error === "string") {
34651
+ let row = now;
34652
+ if (row && row.accessToken === sent.accessToken && !locks()?.request) {
34653
+ await new Promise((r) => setTimeout(r, RACE_WAIT_MS));
34654
+ row = await idbGet("session");
34655
+ }
34656
+ if (row && row.webId === sent.webId && row.accessToken !== sent.accessToken && stillGood(row)) {
34657
+ takeUp(row);
34658
+ return;
34659
+ }
34660
+ if (!row) throw ended("signed out \u2014 sign in again");
34615
34661
  await idbDel("session");
34616
- throw new Error("refresh failed \u2014 sign in again");
34617
- }
34618
- accessToken = tok.access_token;
34619
- expiresAt = Date.now() + Math.max(30, tok.expires_in || 300) * 1e3;
34620
- if (tok.refresh_token) refreshToken = tok.refresh_token;
34621
- const now = await idbGet("session");
34622
- if (now && now.webId !== s.webId) {
34623
- throw new Error("signed in as somebody else while this session was refreshing");
34662
+ throw ended("refresh failed \u2014 sign in again");
34624
34663
  }
34625
- await idbPut("session", { ...s, accessToken, expiresAt, refreshToken });
34626
- };
34664
+ throw new Error(`${host()} could not renew your sign-in just now (HTTP ${res.status})`);
34665
+ });
34627
34666
  const send = async (url, init) => {
34667
+ const { accessToken, pair } = s;
34628
34668
  const jwk = await jwkP;
34629
34669
  const ath = b64u(await sha2562(accessToken));
34630
- const headers = { ...init.headers || {}, authorization: `DPoP ${accessToken}`, dpop: await dpopProof(s.pair, jwk, init.method || "GET", url, ath) };
34670
+ const headers = { ...init.headers || {}, authorization: `DPoP ${accessToken}`, dpop: await dpopProof(pair, jwk, init.method || "GET", url, ath) };
34631
34671
  return fetch(url, { ...init, headers });
34632
34672
  };
34633
34673
  const authFetch = async (url, init = {}) => {
34634
- if (Date.now() > expiresAt - 3e4) await refresh();
34674
+ if (!stillGood(s)) await refresh();
34635
34675
  const res = await send(url, init);
34636
- if (res.status === 401 && refreshToken) {
34676
+ if (res.status === 401 && s.refreshToken) {
34637
34677
  try {
34638
34678
  await refresh();
34639
34679
  } catch {
@@ -34643,7 +34683,17 @@ function solidOidcSession({ dbName = "solid-oidc-session", clientName = "Solid a
34643
34683
  }
34644
34684
  return res;
34645
34685
  };
34646
- return { webId: s.webId, issuer: s.issuer, fetch: authFetch, refresh, signOut: signOut2 };
34686
+ const check = async () => {
34687
+ try {
34688
+ await reread();
34689
+ if (!stillGood(s)) await refresh();
34690
+ return true;
34691
+ } catch (e) {
34692
+ if (e.signedOut) return false;
34693
+ throw e;
34694
+ }
34695
+ };
34696
+ return { webId: first.webId, issuer: first.issuer, fetch: authFetch, refresh, check, signOut: signOut2 };
34647
34697
  }
34648
34698
  return { beginLogin: beginLogin2, completeLogin: completeLogin2, getSession: getSession2, signOut: signOut2 };
34649
34699
  }
@@ -34836,11 +34886,18 @@ window.fedipodOnLoad = async () => {
34836
34886
  await bootWorker({ reset: true });
34837
34887
  return "signed-in";
34838
34888
  }
34839
- if (await getSession()) {
34840
- await bootWorker();
34841
- return "restored";
34889
+ const kept = await getSession();
34890
+ if (!kept) return "anonymous";
34891
+ if (await kept.check().catch(() => true) === false) {
34892
+ try {
34893
+ location.href = (await beginLogin({ issuer: kept.issuer, redirectUri: REDIRECT })).authorizationUrl;
34894
+ return "leaving";
34895
+ } catch {
34896
+ return "anonymous";
34897
+ }
34842
34898
  }
34843
- return "anonymous";
34899
+ await bootWorker();
34900
+ return "restored";
34844
34901
  };
34845
34902
  window.fedipodSignOut = async () => {
34846
34903
  await signOut();
@@ -34943,6 +35000,7 @@ if (typeof document !== "undefined") (async () => {
34943
35000
  };
34944
35001
  const known = {
34945
35002
  "sign-in-refused": { title: "Your pod refused this sign-in", retry: "Sign in again", go: signIn },
35003
+ "signed-out": { title: "Your sign-in has ended", retry: "Sign in again", go: signIn },
34946
35004
  "pod-busy": { title: "Your pod is asking for a pause", retry: "Reload", go: reload, wait: 45 },
34947
35005
  "pod-error": { title: "Your pod had an error", retry: "Reload", go: reload, wait: 20 },
34948
35006
  "pod-unreachable": { title: "Your pod could not be reached", retry: "Reload", go: reload, wait: 20 },
@@ -35018,6 +35076,7 @@ ${e.detail}` : "");
35018
35076
  });
35019
35077
  return;
35020
35078
  }
35079
+ if (state === "leaving") return;
35021
35080
  if (state === "signed-in" || state === "restored") {
35022
35081
  location.href = "/admin/client/";
35023
35082
  return;