fedipod 1.57.0 → 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 +12 -11
- 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 +5 -5
- package/lib/gateway/gateway-core.mjs +15 -3
- package/lib/gateway/held-mail.mjs +56 -25
- package/lib/gateway/keeper-due.mjs +17 -4
- package/lib/gateway/quiet.mjs +11 -1
- package/lib/pod/inbox.mjs +5 -2
- package/package.json +3 -3
- 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
|
@@ -171,11 +171,10 @@ A browser account runs only while FediPod is open somewhere. While it is
|
|
|
171
171
|
closed, fedipod.net does two things for it.
|
|
172
172
|
|
|
173
173
|
**Your mail waits at the gateway.** Deliveries are kept there, not written to
|
|
174
|
-
your pod one by one, and reach your pod in batches of up to a hundred:
|
|
175
|
-
open FediPod, or every fifteen minutes otherwise. While
|
|
176
|
-
goes straight to your pod as before.
|
|
177
|
-
|
|
178
|
-
always goes straight to your pod.
|
|
174
|
+
your pod one by one, and reach your pod in batches of up to a hundred: all of
|
|
175
|
+
them when you open FediPod, or one batch every fifteen minutes otherwise. While
|
|
176
|
+
FediPod is open, mail goes straight to your pod as before. On a gateway whose
|
|
177
|
+
admin has turned the hold off (below), mail always goes straight to your pod.
|
|
179
178
|
|
|
180
179
|
**The gateway keeps your account running.** It accepts your follows, tries
|
|
181
180
|
again what failed to go out, and publishes the posts you scheduled, all while FediPod is closed. Other mail waits on your
|
|
@@ -193,7 +192,8 @@ browser, the gateway and any app you use all work from one copy of your
|
|
|
193
192
|
timeline, notifications, followers, settings and the rest of your FediPod
|
|
194
193
|
state, made at fedipod.net from your pod, so they never disagree. Every
|
|
195
194
|
fifteen minutes, whatever is happening, fedipod.net writes that copy to your
|
|
196
|
-
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
|
|
197
197
|
app, or the gateway's own work while you're away), the copy is made again from
|
|
198
198
|
your pod. You don't notice, except that an app's first check after a deletion
|
|
199
199
|
can take a few seconds longer. When your address moves or is closed, the copy
|
|
@@ -207,7 +207,8 @@ with one setting, `FEDIPOD_HOLD` ([netlify/README.md](netlify/README.md)).
|
|
|
207
207
|
|
|
208
208
|
- **On**, the default and fedipod.net's choice: as above. Mail waits at the
|
|
209
209
|
gateway for up to fifteen minutes, and each account's copy is written to its
|
|
210
|
-
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.
|
|
211
212
|
- **Off**: the gateway keeps no copy of anyone's account and holds no mail.
|
|
212
213
|
Mail goes straight to the pod as it arrives, an app's every check reads the
|
|
213
214
|
pod, and FediPod in the browser works on the pod, taking turns with the
|
|
@@ -278,10 +279,10 @@ door answer 410 Gone, other servers drop the account the next time they look,
|
|
|
278
279
|
and nobody can take the name. Nothing on your pod is touched. An address that
|
|
279
280
|
had already moved to another gateway keeps answering as moved.
|
|
280
281
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
282
|
+
Every person's account is counted: from the last time its owner signed in or
|
|
283
|
+
posted, or from the day it was set up if they never have. An account with no
|
|
284
|
+
day on record is counted from 23 September 2026. A forum, its categories and a
|
|
285
|
+
group are not counted. A gateway operator sets the cap and the window with
|
|
285
286
|
`FEDIPOD_PAUSE_ITEMS` and `FEDIPOD_CLOSE_DAYS`.
|
|
286
287
|
|
|
287
288
|
## Notices
|
|
@@ -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;
|
|
@@ -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 keptForum = !!(ctx.forum?.isForumRow(rec) && rec.keeper); // it acts on no notice that an account was deleted: its door keeps none
|
|
830
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) {
|
|
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)) {
|
|
@@ -168,12 +174,18 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
|
|
|
168
174
|
? signReceipt(makeReceipt(v, { gateway: ident.gatewayWebId }), ident.hmacSecret)
|
|
169
175
|
: null;
|
|
170
176
|
const hash = sha256hex(raw);
|
|
171
|
-
|
|
177
|
+
// The item and its receipt together: on a slow pod, one write after the
|
|
178
|
+
// other outlasted the sender's wait, and the sender sent the item again. A
|
|
179
|
+
// receipt whose item did not land vouches for nothing, and the reader lets
|
|
180
|
+
// it go.
|
|
181
|
+
const [okA] = await Promise.all([
|
|
182
|
+
inbox.appendVerifiedDelivery(podPut, ident.inboxUrl, hash, raw),
|
|
183
|
+
receipt ? inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt) : null,
|
|
184
|
+
]);
|
|
172
185
|
// A pod-write failure returns 5xx so the ORIGIN retries over its own ladder —
|
|
173
186
|
// that is what preserves the pod's buffer property without the gateway
|
|
174
187
|
// holding any state.
|
|
175
188
|
if (!okA) return { status: 502, reason: 'pod inbox write failed' };
|
|
176
|
-
if (receipt) await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
|
|
177
189
|
return { status: 202, reason: v.verified ? 'verified' : failed ? `buffered-unverified: ${failed}` : 'buffered-unverified',
|
|
178
190
|
content, bytes: bytes.byteLength, type: activity.type, notifies: notifies(activity, ident), name: hash, raw, receipt };
|
|
179
191
|
}
|
|
@@ -69,40 +69,55 @@ export const holdingPut = (ctx, handle, rec) => async (url, body, ct) => {
|
|
|
69
69
|
* [{ name, body, receipt, held: [the held names it came from] }]. A receipt
|
|
70
70
|
* whose delivery is not here has nothing to vouch for, and is let go.
|
|
71
71
|
*/
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
// `max`: at most that many deliveries, the first by name, and no more of the
|
|
73
|
+
// pile is listed or read than holds them. Without it, everything.
|
|
74
|
+
export async function heldEntries(ctx, handle, { max = Infinity } = {}) {
|
|
75
|
+
// A receipt may sit beside each delivery: twice as many names hold them whole.
|
|
76
|
+
const names = (await ctx.listHeld(handle, Number.isFinite(max) ? { limit: max * 2 } : {})).sort();
|
|
74
77
|
if (!names.length) return [];
|
|
75
78
|
const items = new Map(); // item name -> { body, receipt, held: [names] }
|
|
76
79
|
for (const name of names) {
|
|
77
|
-
const got = await ctx.readHeld(handle, name);
|
|
78
80
|
const item = name.endsWith(RECEIPT) ? name.slice(0, -RECEIPT.length) : name;
|
|
81
|
+
if (!items.has(item) && items.size >= max) break;
|
|
79
82
|
const e = items.get(item) || { held: [] };
|
|
80
83
|
e.held.push(name);
|
|
81
|
-
if (got != null) {
|
|
82
|
-
if (name.endsWith(RECEIPT)) { try { e.receipt = JSON.parse(got); } catch { e.receipt = null; } } else e.body = got;
|
|
83
|
-
}
|
|
84
84
|
items.set(item, e);
|
|
85
85
|
}
|
|
86
|
-
|
|
86
|
+
await inTurns([...items], async ([item, e]) => {
|
|
87
|
+
for (const name of e.held) {
|
|
88
|
+
const got = await ctx.readHeld(handle, name);
|
|
89
|
+
if (got == null) continue;
|
|
90
|
+
if (name.endsWith(RECEIPT)) { try { e.receipt = JSON.parse(got); } catch { e.receipt = null; } } else e.body = got;
|
|
91
|
+
}
|
|
87
92
|
if (typeof e.body !== 'string') { for (const n of e.held) await ctx.dropHeld(handle, n); items.delete(item); }
|
|
88
|
-
}
|
|
93
|
+
});
|
|
89
94
|
return [...items].map(([name, e]) => ({ name, body: e.body, receipt: e.receipt || null, held: e.held }));
|
|
90
95
|
}
|
|
91
96
|
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
// A few at a time: a pile is read and let go quickly without asking the store
|
|
98
|
+
// for all of it at once.
|
|
99
|
+
const TOGETHER = 6;
|
|
100
|
+
async function inTurns(list, fn) {
|
|
101
|
+
for (let i = 0; i < list.length; i += TOGETHER) await Promise.all(list.slice(i, i + TOGETHER).map(fn));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// What is held for one account, written to its pod inbox in batches and then
|
|
105
|
+
// let go: every batch, or `batches` of them. Returns how many deliveries
|
|
106
|
+
// reached the pod. A batch the pod refuses, or does not take within `podMs`
|
|
107
|
+
// where one is given, stays held for the next try.
|
|
108
|
+
export async function flushHeld(ctx, handle, rec, { now = Date.now(), batches = Infinity, podMs = null } = {}) {
|
|
98
109
|
let delivered = 0;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
110
|
+
const sent = new Set();
|
|
111
|
+
for (let i = 0; i < batches; i++) {
|
|
112
|
+
// A delivery written once is not written again, should letting it go fail.
|
|
113
|
+
const chunk = (await heldEntries(ctx, handle, { max: BATCH_MAX })).filter((e) => !sent.has(e.name));
|
|
114
|
+
if (!chunk.length) break;
|
|
115
|
+
const batch = chunk.map((e) => ({ name: e.name, body: e.body, receipt: e.receipt || null }));
|
|
116
|
+
const doc = `batch-${now}-${i}-${Math.random().toString(36).slice(2, 8)}.json`;
|
|
117
|
+
const ok = await ctx.podPut(handle, inboxOf(rec) + doc, JSON.stringify({ batch }), 'application/json', podMs ? { timeoutMs: podMs } : undefined);
|
|
104
118
|
if (!ok) break;
|
|
105
|
-
for (const
|
|
119
|
+
for (const e of chunk) sent.add(e.name);
|
|
120
|
+
await inTurns(chunk.flatMap((e) => e.held), (n) => ctx.dropHeld(handle, n));
|
|
106
121
|
delivered += chunk.length;
|
|
107
122
|
}
|
|
108
123
|
return delivered;
|
|
@@ -132,17 +147,33 @@ export async function afterHeld(ctx, handle, rec, { type, notifies = false } = {
|
|
|
132
147
|
// is gone, closed or moved has nowhere to deliver to, so its mail is let go.
|
|
133
148
|
// `keep(handle, rec)` answers true when the account's keeper will take its mail
|
|
134
149
|
// itself (lib/gateway/keeper.mjs), which delivers it and then reads it.
|
|
150
|
+
//
|
|
151
|
+
// The round has to end. So an account gets one batch a round, a pod has
|
|
152
|
+
// ROUND_POD_MS to take it, a few accounts are worked on at a time, and each
|
|
153
|
+
// round begins after the account the last one finished with (ctx.roundMark,
|
|
154
|
+
// ctx.setRoundMark): a round cut short leaves out different accounts each time.
|
|
155
|
+
export const ROUND_POD_MS = 10_000;
|
|
156
|
+
const ROUND_TOGETHER = 4;
|
|
135
157
|
export async function flushAll(ctx, { log = console.log, isGone = async () => false, keep = async () => false } = {}) {
|
|
136
|
-
|
|
158
|
+
const all = [...await ctx.heldAccounts()].sort();
|
|
159
|
+
const mark = await ctx.roundMark?.().catch(() => null);
|
|
160
|
+
const from = mark ? all.findIndex((h) => h > mark) : 0;
|
|
161
|
+
const order = from > 0 ? [...all.slice(from), ...all.slice(0, from)] : all;
|
|
162
|
+
const one = async (handle) => {
|
|
137
163
|
const rec = await ctx.lookup(handle);
|
|
138
164
|
if (!rec || rec.movedTo || await isGone(handle, rec)) {
|
|
139
|
-
|
|
165
|
+
await inTurns(await ctx.listHeld(handle, { limit: BATCH_MAX * 2 }), (n) => ctx.dropHeld(handle, n));
|
|
140
166
|
log(`held mail @${handle}: let go (no account to deliver to)`);
|
|
141
|
-
|
|
167
|
+
return;
|
|
142
168
|
}
|
|
143
|
-
if (await keep(handle, rec).catch(() => false))
|
|
144
|
-
const n = await flushHeld(ctx, handle, rec
|
|
169
|
+
if (await keep(handle, rec).catch(() => false)) return;
|
|
170
|
+
const n = await flushHeld(ctx, handle, rec, { batches: 1, podMs: ROUND_POD_MS });
|
|
145
171
|
if (n) log(`held mail @${handle}: ${n} delivered to the pod`);
|
|
172
|
+
};
|
|
173
|
+
for (let i = 0; i < order.length; i += ROUND_TOGETHER) {
|
|
174
|
+
const turn = order.slice(i, i + ROUND_TOGETHER);
|
|
175
|
+
await Promise.all(turn.map((h) => one(h).catch((e) => log(`held mail @${h}: ${e?.message || e}`))));
|
|
176
|
+
await ctx.setRoundMark?.(turn.at(-1)).catch(() => {});
|
|
146
177
|
}
|
|
147
178
|
}
|
|
148
179
|
|
|
@@ -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/lib/gateway/quiet.mjs
CHANGED
|
@@ -4,7 +4,17 @@
|
|
|
4
4
|
// public read, stamps noteOpened from the relay, counts noteReceived after
|
|
5
5
|
// a forwarded delivery, and hands /api/open, /api/pause and /api/close here.
|
|
6
6
|
|
|
7
|
-
import { keptNow, holdOn } from './copy.mjs';
|
|
7
|
+
import { keptNow, holdOn, isPersonal } from './copy.mjs';
|
|
8
|
+
|
|
9
|
+
// A gateway whose people all run FediPod in a browser gives every person's
|
|
10
|
+
// account a day its owner was last here, so none is left out of what follows:
|
|
11
|
+
// the day it was attached, or the day these dates began for a row that has
|
|
12
|
+
// neither. A forum, a group and a forum's category are nobody's to open.
|
|
13
|
+
export const FIRST_OPENED = '2026-09-23T00:00:00.000Z';
|
|
14
|
+
export function withOpened(rec) {
|
|
15
|
+
if (!rec || rec.openedAt || !isPersonal(rec)) return rec;
|
|
16
|
+
return { ...rec, openedAt: rec.attachedAt || FIRST_OPENED };
|
|
17
|
+
}
|
|
8
18
|
|
|
9
19
|
// ---- accounts that go quiet -------------------------------------------------
|
|
10
20
|
//
|
package/lib/pod/inbox.mjs
CHANGED
|
@@ -20,12 +20,15 @@ const RECEIPT_CT = 'application/json';
|
|
|
20
20
|
* public Append the pod may well allow. This is the one place that gets to
|
|
21
21
|
* decide that, because it lived in two adapters and had to be right in both.
|
|
22
22
|
*
|
|
23
|
+
* `timeoutMs`, when given, is how long the pod has to answer; a pod that takes
|
|
24
|
+
* longer counts as one that did not answer.
|
|
25
|
+
*
|
|
23
26
|
* @returns {Promise<boolean>} whether it landed
|
|
24
27
|
*/
|
|
25
|
-
export async function appendWithToken(url, body, contentType, { appendToken = null, fetchImpl = fetch, report = null } = {}) {
|
|
28
|
+
export async function appendWithToken(url, body, contentType, { appendToken = null, fetchImpl = fetch, report = null, timeoutMs = null } = {}) {
|
|
26
29
|
const headers = { 'content-type': contentType,
|
|
27
30
|
...(appendToken ? { authorization: `Bearer ${appendToken}` } : {}) };
|
|
28
|
-
const r = await fetchImpl(url, { method: 'PUT', headers, body }).catch(() => null);
|
|
31
|
+
const r = await fetchImpl(url, { method: 'PUT', headers, body, ...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}) }).catch(() => null);
|
|
29
32
|
// The pod's answer, for a caller that keeps a log: a refused write is
|
|
30
33
|
// otherwise a bare "failed" with the status thrown away.
|
|
31
34
|
report?.(r ? r.status : 0);
|
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",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"install-service": "node bin/fedipod.mjs install-service",
|
|
56
56
|
"uninstall-service": "node bin/fedipod.mjs uninstall-service",
|
|
57
57
|
"prepublishOnly": "npm test",
|
|
58
|
-
"test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/hold-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node claude/smoke-tests/admin-facade-smoke.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs claude/smoke-tests/oauth-signin.test.mjs claude/smoke-tests/app-registration.test.mjs claude/smoke-tests/push-signout.test.mjs claude/smoke-tests/token-fetches.test.mjs claude/smoke-tests/signin-hardening.test.mjs claude/smoke-tests/load-fixes.test.mjs claude/smoke-tests/unread-docs.test.mjs claude/smoke-tests/post-text-once.test.mjs claude/smoke-tests/connected-pod-only.test.mjs claude/smoke-tests/check-letter.test.mjs claude/smoke-tests/gone-through-relay.test.mjs claude/smoke-tests/connected-feeds.test.mjs claude/smoke-tests/forum-pages.test.mjs && node scripts/check-sizes.mjs && node scripts/test-dependents.mjs && node claude/validation/browser-agent/all.mjs",
|
|
58
|
+
"test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/hold-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node claude/smoke-tests/admin-facade-smoke.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs claude/smoke-tests/oauth-signin.test.mjs claude/smoke-tests/app-registration.test.mjs claude/smoke-tests/push-signout.test.mjs claude/smoke-tests/token-fetches.test.mjs claude/smoke-tests/signin-hardening.test.mjs claude/smoke-tests/load-fixes.test.mjs claude/smoke-tests/unread-docs.test.mjs claude/smoke-tests/post-text-once.test.mjs claude/smoke-tests/connected-pod-only.test.mjs claude/smoke-tests/check-letter.test.mjs claude/smoke-tests/gone-through-relay.test.mjs claude/smoke-tests/connected-feeds.test.mjs claude/smoke-tests/forum-pages.test.mjs claude/smoke-tests/round.test.mjs && node scripts/check-sizes.mjs && node scripts/test-dependents.mjs && node claude/validation/browser-agent/all.mjs",
|
|
59
59
|
"verify-ui": "node scripts/verify-ui.mjs",
|
|
60
60
|
"build:sw": "node scripts/build-app.mjs --entry web/app/sw-src.mjs --out web/app/dist/sw.js",
|
|
61
61
|
"build:boot": "node scripts/build-app.mjs --entry web/app/boot.mjs --out web/app/dist/boot.js",
|
|
@@ -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.
|