fedipod 1.57.0 → 1.57.2
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 +8 -9
- package/lib/gateway/front-core.mjs +4 -4
- package/lib/gateway/gateway-core.mjs +12 -3
- package/lib/gateway/held-mail.mjs +68 -25
- package/lib/gateway/quiet.mjs +11 -1
- package/lib/pod/inbox.mjs +5 -2
- package/package.json +2 -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
|
|
@@ -278,10 +277,10 @@ door answer 410 Gone, other servers drop the account the next time they look,
|
|
|
278
277
|
and nobody can take the name. Nothing on your pod is touched. An address that
|
|
279
278
|
had already moved to another gateway keeps answering as moved.
|
|
280
279
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
280
|
+
Every person's account is counted: from the last time its owner signed in or
|
|
281
|
+
posted, or from the day it was set up if they never have. An account with no
|
|
282
|
+
day on record is counted from 23 September 2026. A forum, its categories and a
|
|
283
|
+
group are not counted. A gateway operator sets the cap and the window with
|
|
285
284
|
`FEDIPOD_PAUSE_ITEMS` and `FEDIPOD_CLOSE_DAYS`.
|
|
286
285
|
|
|
287
286
|
## Notices
|
|
@@ -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, routeHereApi, routeKeeperApi, routePushApi, routeAlertApi } from './held-mail.mjs';
|
|
26
|
+
import { holdsMail, mayBeAway, isPresent, holdingPut, afterHeld, waitsForRound, 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,7 +827,7 @@ 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, notifies, name, raw, receipt } = await handleDelivery(request, identFor(rec, policy),
|
|
830
|
+
const { status, reason, content, bytes, type, accountDeleted, notifies, name, raw, receipt } = await handleDelivery(request, identFor(rec, policy),
|
|
831
831
|
{ podPut: held ? holdingPut(ctx, up.handle, rec) : (u, b, ct) => ctx.podPut(up.handle, u, b, ct),
|
|
832
832
|
fetchImpl: ctx.fetchImpl, paused: standing.paused, origin: ctx.frontOrigin || null });
|
|
833
833
|
// One line per delivery, so "did it arrive at the door" has an answer.
|
|
@@ -836,8 +836,8 @@ async function route(request, ctx) {
|
|
|
836
836
|
if (away && status === 202) await afterHeld(ctx, up.handle, rec, { type, notifies });
|
|
837
837
|
// A forum fedipod.net keeps: the delivery is placed now, in this request,
|
|
838
838
|
// and the run that carries it is started. Whatever could not be placed
|
|
839
|
-
// here is still in the inbox for that run.
|
|
840
|
-
if (status === 202 && name && ctx.forum?.isForumRow(rec) && rec.keeper) {
|
|
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 })) {
|
|
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})`}`);
|
|
@@ -168,14 +168,23 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
|
|
|
168
168
|
? signReceipt(makeReceipt(v, { gateway: ident.gatewayWebId }), ident.hmacSecret)
|
|
169
169
|
: null;
|
|
170
170
|
const hash = sha256hex(raw);
|
|
171
|
-
|
|
171
|
+
// The item and its receipt together: on a slow pod, one write after the
|
|
172
|
+
// other outlasted the sender's wait, and the sender sent the item again. A
|
|
173
|
+
// receipt whose item did not land vouches for nothing, and the reader lets
|
|
174
|
+
// it go.
|
|
175
|
+
const [okA] = await Promise.all([
|
|
176
|
+
inbox.appendVerifiedDelivery(podPut, ident.inboxUrl, hash, raw),
|
|
177
|
+
receipt ? inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt) : null,
|
|
178
|
+
]);
|
|
172
179
|
// A pod-write failure returns 5xx so the ORIGIN retries over its own ladder —
|
|
173
180
|
// that is what preserves the pod's buffer property without the gateway
|
|
174
181
|
// holding any state.
|
|
175
182
|
if (!okA) return { status: 502, reason: 'pod inbox write failed' };
|
|
176
|
-
|
|
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.
|
|
177
185
|
return { status: 202, reason: v.verified ? 'verified' : failed ? `buffered-unverified: ${failed}` : 'buffered-unverified',
|
|
178
|
-
content, bytes: bytes.byteLength, type: activity.type,
|
|
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 };
|
|
179
188
|
}
|
|
180
189
|
|
|
181
190
|
// The outbox door: the owner's own post, taken on their behalf.
|
|
@@ -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;
|
|
@@ -118,6 +133,18 @@ export async function wakesKeeper(ctx, handle, rec, type) {
|
|
|
118
133
|
return true;
|
|
119
134
|
}
|
|
120
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
|
+
|
|
121
148
|
// After a delivery is held: a follow wakes the keeper, and anything that
|
|
122
149
|
// becomes a notification is read in and pushed at once to the phones and
|
|
123
150
|
// browsers the owner signed up for notifications (masto-gateway.mjs: pushHeld).
|
|
@@ -132,17 +159,33 @@ export async function afterHeld(ctx, handle, rec, { type, notifies = false } = {
|
|
|
132
159
|
// is gone, closed or moved has nowhere to deliver to, so its mail is let go.
|
|
133
160
|
// `keep(handle, rec)` answers true when the account's keeper will take its mail
|
|
134
161
|
// itself (lib/gateway/keeper.mjs), which delivers it and then reads it.
|
|
162
|
+
//
|
|
163
|
+
// The round has to end. So an account gets one batch a round, a pod has
|
|
164
|
+
// ROUND_POD_MS to take it, a few accounts are worked on at a time, and each
|
|
165
|
+
// round begins after the account the last one finished with (ctx.roundMark,
|
|
166
|
+
// ctx.setRoundMark): a round cut short leaves out different accounts each time.
|
|
167
|
+
export const ROUND_POD_MS = 10_000;
|
|
168
|
+
const ROUND_TOGETHER = 4;
|
|
135
169
|
export async function flushAll(ctx, { log = console.log, isGone = async () => false, keep = async () => false } = {}) {
|
|
136
|
-
|
|
170
|
+
const all = [...await ctx.heldAccounts()].sort();
|
|
171
|
+
const mark = await ctx.roundMark?.().catch(() => null);
|
|
172
|
+
const from = mark ? all.findIndex((h) => h > mark) : 0;
|
|
173
|
+
const order = from > 0 ? [...all.slice(from), ...all.slice(0, from)] : all;
|
|
174
|
+
const one = async (handle) => {
|
|
137
175
|
const rec = await ctx.lookup(handle);
|
|
138
176
|
if (!rec || rec.movedTo || await isGone(handle, rec)) {
|
|
139
|
-
|
|
177
|
+
await inTurns(await ctx.listHeld(handle, { limit: BATCH_MAX * 2 }), (n) => ctx.dropHeld(handle, n));
|
|
140
178
|
log(`held mail @${handle}: let go (no account to deliver to)`);
|
|
141
|
-
|
|
179
|
+
return;
|
|
142
180
|
}
|
|
143
|
-
if (await keep(handle, rec).catch(() => false))
|
|
144
|
-
const n = await flushHeld(ctx, handle, rec
|
|
181
|
+
if (await keep(handle, rec).catch(() => false)) return;
|
|
182
|
+
const n = await flushHeld(ctx, handle, rec, { batches: 1, podMs: ROUND_POD_MS });
|
|
145
183
|
if (n) log(`held mail @${handle}: ${n} delivered to the pod`);
|
|
184
|
+
};
|
|
185
|
+
for (let i = 0; i < order.length; i += ROUND_TOGETHER) {
|
|
186
|
+
const turn = order.slice(i, i + ROUND_TOGETHER);
|
|
187
|
+
await Promise.all(turn.map((h) => one(h).catch((e) => log(`held mail @${h}: ${e?.message || e}`))));
|
|
188
|
+
await ctx.setRoundMark?.(turn.at(-1)).catch(() => {});
|
|
146
189
|
}
|
|
147
190
|
}
|
|
148
191
|
|
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.2",
|
|
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",
|