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 +4 -2
- package/lib/core/intake/group.mjs +5 -3
- package/lib/core/intake/index.mjs +8 -2
- package/lib/gateway/copy.mjs +23 -2
- package/lib/gateway/front-core.mjs +7 -7
- package/lib/gateway/gateway-core.mjs +8 -5
- package/lib/gateway/held-mail.mjs +0 -12
- package/lib/gateway/keeper-due.mjs +17 -4
- package/package.json +2 -2
- package/scripts/check-netlify-bundles.mjs +7 -2
- package/scripts/forum-package.mjs +28 -6
- package/web/app/boot.mjs +21 -5
- package/web/app/dist/boot.js +88 -29
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +99 -30
- package/web/app/dist/sw.js.map +3 -3
- package/web/app/sw-src.mjs +23 -2
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.
|
|
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
|
-
|
|
23
|
-
//
|
|
24
|
-
|
|
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
|
-
|
|
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
|
package/lib/gateway/copy.mjs
CHANGED
|
@@ -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
|
|
262
|
-
|
|
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,
|
|
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
|
|
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
|
-
//
|
|
838
|
-
//
|
|
839
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
14
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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' —
|
|
226
|
-
// in the middle of creating an account: with no account
|
|
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
|
-
|
|
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.
|
package/web/app/dist/boot.js
CHANGED
|
@@ -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
|
-
|
|
34595
|
-
|
|
34596
|
-
|
|
34597
|
-
|
|
34598
|
-
|
|
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(
|
|
34625
|
+
res = await fetch(sent.tokenEndpoint, {
|
|
34603
34626
|
method: "POST",
|
|
34604
|
-
headers: { "content-type": "application/x-www-form-urlencoded", dpop: await dpopProof(
|
|
34605
|
-
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id:
|
|
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 ${
|
|
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(`${
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
34840
|
-
|
|
34841
|
-
|
|
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
|
-
|
|
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;
|