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 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: when you
175
- open FediPod, or every fifteen minutes otherwise. While FediPod is open, mail
176
- goes straight to your pod as before. A DeviceAgent's mail always goes straight
177
- to its pod. On a gateway whose admin has turned the hold off (below), mail
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. The next time anything needs your account (FediPod, an
195
+ pod and deletes it. If your pod will not take it, the copy stays at
196
+ fedipod.net and is tried again an hour later. The next time anything needs your account (FediPod, an
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
- Only accounts opened from a browser are counted. A DeviceAgent behind the
282
- gateway drains its own inbox as it runs, and is never paused or closed by
283
- time. Accounts from before this was built are counted from their next
284
- sign-in. A gateway operator sets the cap and the window with
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
- // Only a post the group holds and did not author — removing those is
23
- // moderation; everything else is the author's own Delete.
24
- return !!s && s.kind !== 'post';
22
+ const by = typeof activity.actor === 'string' ? activity.actor : activity.actor?.id;
23
+ // Only a post the group holds, written by neither the group nor whoever
24
+ // is deleting it: removing those is moderation. Everything else is the
25
+ // author's own Delete, a moderator's of their own post included.
26
+ return !!s && s.kind !== 'post' && s.actor !== by;
25
27
  }
26
28
  if (activity.type === 'Add' || activity.type === 'Remove') {
27
29
  const target = typeof activity.target === 'string' ? activity.target : activity.target?.id;
@@ -370,6 +370,9 @@ export class Intake {
370
370
  }
371
371
 
372
372
  async _drainOnce() {
373
+ // Whether this sweep took mail in and left more in the inbox: a caller that
374
+ // runs one sweep and stops (a forum kept by a gateway) asks to come back.
375
+ this.moreWaiting = false;
373
376
  this._sweepGen = this.store.generation;
374
377
  const cooling = this.drainCooldownUntil - Date.now();
375
378
  if (cooling > 0) {
@@ -452,9 +455,10 @@ export class Intake {
452
455
  };
453
456
 
454
457
  let spent = 0; // deliveries taken this sweep, a batch counting each of its own
458
+ let cut = false; // stopped at the cap with the listing not finished
455
459
  for (const { url, size, receipt: hasReceipt } of items) {
456
460
  if (url.endsWith('.keep')) continue;
457
- if (spent >= MAX_ITEMS_PER_DRAIN) { this._drainAgain = handled > 0 || pending.length > 0; break; }
461
+ if (spent >= MAX_ITEMS_PER_DRAIN) { cut = this._drainAgain = handled > 0 || pending.length > 0; break; }
458
462
  if (isBatch(url)) {
459
463
  try {
460
464
  const took = await this._takeBatch(url);
@@ -531,7 +535,9 @@ export class Intake {
531
535
  // Made progress and there is more waiting: go straight round rather than
532
536
  // sleeping. Gated on progress so a sweep that achieved nothing — a
533
537
  // cooldown, an unwritable store, poison at the head — cannot spin.
534
- if (handled > 0 && all.length > items.length && !this.stopped) this._drainAgain = true;
538
+ const more = handled > 0 && all.length > items.length;
539
+ this.moreWaiting = more || cut;
540
+ if (more && !this.stopped) this._drainAgain = true;
535
541
  }
536
542
 
537
543
  // One delivery, alone in the inbox or in a batch: read as JSON-LD, checked
@@ -258,8 +258,10 @@ export class CopyStorage {
258
258
  * Held for `ms` at a time and extended while its holder runs, so a long run
259
259
  * keeps it and a holder that died loses it within `ms`.
260
260
  */
261
- export async function lockCopy(kv, h, { ms = 60_000, waitMs = 8_000, by = crypto.randomUUID() } = {}) {
262
- const key = `${account(h)}/lock`;
261
+ export const lockCopy = (kv, h, opts) => lockKey(kv, `${account(h)}/lock`, opts);
262
+
263
+ /** The same lock under any key: the round holds one, so two rounds never run at once. */
264
+ export async function lockKey(kv, key, { ms = 60_000, waitMs = 8_000, by = crypto.randomUUID() } = {}) {
263
265
  const give = (etag) => {
264
266
  let tag = etag;
265
267
  const timer = setInterval(async () => {
@@ -488,6 +490,25 @@ export async function holdOver(kv, h, { pod, podFetch, stateUrl, log = () => {}
488
490
  } finally { await kv.delete(`${h}/closing`).catch(() => {}); }
489
491
  }
490
492
 
493
+ /**
494
+ * A pod that would not take an account's copy is left alone for COPY_RETRY_MS
495
+ * before the round asks it again (flush-mail.mjs): asked every round, a pod
496
+ * that goes on refusing took a quarter of the round. With no wait (`waitMs`
497
+ * 0), every round asks. `refusedAt` answers when the pod last refused, or 0
498
+ * when the account is to be tried; `afterTry` records how the try went.
499
+ */
500
+ export const COPY_RETRY_MS = 3_600_000;
501
+ export async function refusedAt(kv, h, { waitMs = COPY_RETRY_MS, now = Date.now() } = {}) {
502
+ if (!waitMs) return 0;
503
+ const at = (await readJson(kv, `${account(h)}/refused`)).doc?.at || 0;
504
+ return at && now - at < waitMs ? at : 0;
505
+ }
506
+ export async function afterTry(kv, h, ok, { waitMs = COPY_RETRY_MS, now = Date.now() } = {}) {
507
+ if (!waitMs) return;
508
+ if (!ok) await kv.set(`${account(h)}/refused`, JSON.stringify({ at: now }));
509
+ else if (await kv.get(`${h}/refused`)) await kv.delete(`${h}/refused`);
510
+ }
511
+
491
512
  /** A copy listed with no meta is one whose delete was cut off: its documents go. */
492
513
  export async function clearLeftover(kv, h, { log = () => {} } = {}) {
493
514
  if (await copyMeta(kv, h)) return false;
@@ -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
- // A forum fedipod.net keeps: the delivery is placed now, in this request,
838
- // and the run that carries it is started. Whatever could not be placed
839
- // here is still in the inbox for that run.
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
- export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES, paused = false, origin = null, admit = null } = {}) {
118
+ // `dropAccountDeleted`: a sender's notice that its own account is gone is
119
+ // answered and written nowhere, for an inbox whose reader acts on none.
120
+ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES, paused = false, origin = null, admit = null, dropAccountDeleted = false } = {}) {
119
121
  // The body, once, as the bytes the sender digested; the text of it is what
120
122
  // is filed and hashed, as before.
121
123
  let bytes, raw;
@@ -131,6 +133,10 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
131
133
  // Edge drops — silent 202 so a rejected sender does not retry a delivery we
132
134
  // will never accept. None of these becomes a pod write.
133
135
  if (isBlocked(actor, ident.blocklist)) return { status: 202, reason: 'blocked' };
136
+ // Mastodon sends one of these to every inbox it knows, for every account it deletes.
137
+ if (dropAccountDeleted && activity.type === 'Delete' && idOf(activity.object) === actor) {
138
+ return { status: 202, reason: "an account's deletion, not kept" };
139
+ }
134
140
  const content = !CONTROL.has(activity.type);
135
141
  if (content && paused) return { status: 202, reason: 'paused' };
136
142
  if (content && !concernsUsAtEdge(activity, ident)) {
@@ -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
- const okA = await inbox.appendVerifiedDelivery(podPut, ident.inboxUrl, hash, raw);
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
- export async function heldEntries(ctx, handle) {
73
- const names = await ctx.listHeld(handle);
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
- for (const [item, e] of items) {
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
- // Everything held for one account, written to its pod inbox in batches and
93
- // then let go. Returns how many deliveries reached the pod. A batch the pod
94
- // refuses stays held for the next try.
95
- export async function flushHeld(ctx, handle, rec, { now = Date.now() } = {}) {
96
- const all = (await heldEntries(ctx, handle)).map((e) => [e.name, e]);
97
- if (!all.length) return 0;
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
- for (let i = 0; i < all.length; i += BATCH_MAX) {
100
- const chunk = all.slice(i, i + BATCH_MAX);
101
- const batch = chunk.map(([name, e]) => ({ name, body: e.body, receipt: e.receipt || null }));
102
- const doc = `batch-${now}-${i / BATCH_MAX}-${Math.random().toString(36).slice(2, 8)}.json`;
103
- const ok = await ctx.podPut(handle, inboxOf(rec) + doc, JSON.stringify({ batch }), 'application/json');
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 [, e] of chunk) for (const n of e.held) await ctx.dropHeld(handle, n);
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
- for (const handle of await ctx.heldAccounts()) {
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
- for (const n of await ctx.listHeld(handle)) await ctx.dropHeld(handle, n);
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
- continue;
167
+ return;
142
168
  }
143
- if (await keep(handle, rec).catch(() => false)) continue;
144
- const n = await flushHeld(ctx, handle, rec).catch((e) => { log(`held mail @${handle}: ${e?.message || e}`); return 0; });
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
- // The sooner of two times (ISO strings, either of them null).
14
- export const earliest = (a, b) => (!a ? b || null : !b ? a : Date.parse(a) <= Date.parse(b) ? a : b);
16
+ // A time as an ISO string, or null where there is none to read.
17
+ const iso = (t) => {
18
+ const ms = typeof t === 'number' ? t : t ? Date.parse(t) : NaN;
19
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
20
+ };
21
+
22
+ // The sooner of two times (either of them null).
23
+ export const earliest = (a, b) => {
24
+ const [x, y] = [iso(a), iso(b)];
25
+ return !x ? y : !y ? x : Date.parse(x) <= Date.parse(y) ? x : y;
26
+ };
15
27
 
16
28
  export function keeperBook(store) {
17
29
  return {
18
30
  async noteKept(handle, out, started = 0, now = Date.now()) {
19
31
  const prev = (await store.get(handle)) || {};
20
32
  const next = out?.retry === 'soon' ? prev.nextAt
21
- : out?.retry === 'later' ? new Date(now + RETRY_FAILED_MS).toISOString() : out?.nextAt;
33
+ : out?.retry === 'later' ? new Date(now + RETRY_FAILED_MS).toISOString() : iso(out?.nextAt);
22
34
  const woken = prev.noted >= started ? prev.nextAt : null;
23
35
  await store.set(handle, { at: now, waiting: out?.waiting || 0, skipped: out?.skipped || null,
24
36
  nextAt: earliest(next || null, woken) });
@@ -31,7 +43,8 @@ export function keeperBook(store) {
31
43
  const due = [];
32
44
  for (const key of await store.keys()) {
33
45
  const k = (await store.get(key)) || {};
34
- if (k.nextAt && Date.parse(k.nextAt) <= now) due.push(key);
46
+ const at = iso(k.nextAt);
47
+ if (at && Date.parse(at) <= now) due.push(key);
35
48
  }
36
49
  return due;
37
50
  },
@@ -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.0",
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.1",
79
+ "fediverse-session": "^0.2.2",
80
80
  "jose": "^6.2.8",
81
81
  "json-canon": "^1.0.1",
82
82
  "jsonld": "^9.0.0",
@@ -18,9 +18,14 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fn-bundles-'));
18
18
  let fails = 0;
19
19
  const check = (ok, msg) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${msg}`); if (!ok) fails++; };
20
20
  try {
21
- // The functions carry the forum as it is in its own folder now.
22
- const { bringForum } = await import('./forum-package.mjs');
21
+ // The functions carry the forum as it is in its own folder now, running on
22
+ // this folder's FediPod: on an older copy the forum lacked the day's fixes
23
+ // while every test, which runs it on this folder, passed (1.57.3, 2026-11-14).
24
+ const { bringForum, forumCoreDiffers } = await import('./forum-package.mjs');
23
25
  bringForum();
26
+ const behind = forumCoreDiffers();
27
+ check(behind.length === 0, `the forum runs on this folder's FediPod${behind.length
28
+ ? ` (${behind.length} file(s) under lib/ differ: ${behind.slice(0, 5).join(', ')}${behind.length > 5 ? ', …' : ''})` : ''}`);
24
29
  execFileSync('netlify', ['functions:build', '--src', path.join(root, 'netlify/functions'), '--functions', path.join(tmp, 'zips')],
25
30
  { cwd: root, stdio: 'ignore' });
26
31
  const manifest = JSON.parse(fs.readFileSync(path.join(tmp, 'zips/manifest.json'), 'utf8'));
@@ -5,22 +5,44 @@
5
5
  // publish it and put in node_modules without touching FediPod's package list,
6
6
  // so nothing FediPod records names a forum version. Staging the site, the
7
7
  // bundle check and the app build each bring it first.
8
+ //
9
+ // The forum runs on FediPod, and here that is this folder: packed the same
10
+ // way and installed beside the forum, so the forum's `fedipod` is the code
11
+ // being deployed and not the one the registry has.
8
12
  import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';
9
13
  import { execFileSync } from 'node:child_process';
14
+ import { createRequire } from 'node:module';
10
15
  import { fetchDependent, root } from './dependents.mjs';
11
16
 
12
- /** The forum from its folder, installed in node_modules. Returns its folder and version. */
17
+ /** The forum from its folder, and this folder for it to run on, installed in node_modules. Returns the forum's folder and version. */
13
18
  export function bringForum({ log = console.log } = {}) {
14
19
  const dir = fetchDependent('fedipod-bb', { log });
15
20
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'forum-pack-'));
16
21
  try {
17
- const packed = JSON.parse(execFileSync('npm', ['pack', dir, '--pack-destination', tmp, '--ignore-scripts', '--json'],
22
+ const pack = (from) => JSON.parse(execFileSync('npm', ['pack', from, '--pack-destination', tmp, '--ignore-scripts', '--json'],
18
23
  { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }))[0];
19
- execFileSync('npm', ['install', '--no-save', '--no-audit', '--no-fund', '--ignore-scripts', path.join(tmp, packed.filename)],
20
- { cwd: root, stdio: 'ignore' });
21
- log(`forum: fedipod-bb ${packed.version} from ${path.relative(root, dir) || dir}`);
22
- return { dir, version: packed.version };
24
+ const forum = pack(dir);
25
+ const self = pack(root);
26
+ execFileSync('npm', ['install', '--no-save', '--no-audit', '--no-fund', '--ignore-scripts',
27
+ path.join(tmp, forum.filename), path.join(tmp, self.filename)], { cwd: root, stdio: 'ignore' });
28
+ log(`forum: fedipod-bb ${forum.version} from ${path.relative(root, dir) || dir}, on fedipod ${self.version} from this folder`);
29
+ return { dir, version: forum.version };
23
30
  } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
24
31
  }
25
32
 
33
+ /**
34
+ * The files under lib/ that the forum's `fedipod` and this folder do not
35
+ * have the same. Empty when the forum runs on this folder's code.
36
+ */
37
+ export function forumCoreDiffers() {
38
+ const theirs = path.join(path.dirname(createRequire(path.join(root, 'node_modules/fedipod-bb/package.json')).resolve('fedipod/package.json')), 'lib');
39
+ const ours = path.join(root, 'lib');
40
+ const files = (base) => fs.readdirSync(base, { recursive: true, withFileTypes: true })
41
+ .filter((e) => e.isFile()).map((e) => path.relative(base, path.join(e.parentPath, e.name)));
42
+ const same = (rel) => {
43
+ try { return fs.readFileSync(path.join(ours, rel)).equals(fs.readFileSync(path.join(theirs, rel))); } catch { return false; }
44
+ };
45
+ return [...new Set([...files(ours), ...files(theirs)])].filter((rel) => !same(rel)).sort();
46
+ }
47
+
26
48
  if (import.meta.url === `file://${process.argv[1]}`) bringForum();
package/web/app/boot.mjs CHANGED
@@ -222,9 +222,10 @@ window.fedipodSignin = async ({ address }) => {
222
222
  };
223
223
 
224
224
  // Called on every page load. Returns 'signed-in' | 'restored' | 'anonymous' |
225
- // 'signup' | 'move-in' — the last two being a return from the pod's login
226
- // in the middle of creating an account: with no account on the pod yet, or
227
- // with one whose address lives at another gateway.
225
+ // 'signup' | 'move-in' | 'leaving' — 'signup' and 'move-in' being a return
226
+ // from the pod's login in the middle of creating an account: with no account
227
+ // on the pod yet, or with one whose address lives at another gateway.
228
+ // 'leaving': the page is on its way to the pod's login.
228
229
  window.fedipodOnLoad = async () => {
229
230
  if (new URLSearchParams(location.search).get('code')) {
230
231
  const done = await completeLogin({ currentUrl: location.href });
@@ -240,8 +241,21 @@ window.fedipodOnLoad = async () => {
240
241
  await bootWorker({ reset: true }); // this may be a different account
241
242
  return 'signed-in';
242
243
  }
243
- if (await getSession()) { await bootWorker(); return 'restored'; }
244
- return 'anonymous';
244
+ const kept = await getSession();
245
+ if (!kept) return 'anonymous';
246
+ // What the browser has stored says a sign-in was made, not that the pod
247
+ // still honours it. The pod is asked before anything is started on it, and
248
+ // only when the sign-in's time is up. One the pod has ended goes to the
249
+ // pod's own login, which the stored sign-in names: nobody is asked to press
250
+ // a button for it. A pod that cannot be asked is left to the start below.
251
+ if (await kept.check().catch(() => true) === false) {
252
+ try {
253
+ location.href = (await beginLogin({ issuer: kept.issuer, redirectUri: REDIRECT })).authorizationUrl;
254
+ return 'leaving';
255
+ } catch { return 'anonymous'; } // its login cannot be reached: the sign-in page, as for anybody
256
+ }
257
+ await bootWorker();
258
+ return 'restored';
245
259
  };
246
260
 
247
261
  window.fedipodSignOut = async () => {
@@ -366,6 +380,7 @@ if (typeof document !== 'undefined') (async () => {
366
380
  const signIn = () => { location.href = '/?add'; };
367
381
  const known = {
368
382
  'sign-in-refused': { title: 'Your pod refused this sign-in', retry: 'Sign in again', go: signIn },
383
+ 'signed-out': { title: 'Your sign-in has ended', retry: 'Sign in again', go: signIn },
369
384
  'pod-busy': { title: 'Your pod is asking for a pause', retry: 'Reload', go: reload, wait: 45 },
370
385
  'pod-error': { title: 'Your pod had an error', retry: 'Reload', go: reload, wait: 20 },
371
386
  'pod-unreachable': { title: 'Your pod could not be reached', retry: 'Reload', go: reload, wait: 20 },
@@ -440,6 +455,7 @@ if (typeof document !== 'undefined') (async () => {
440
455
  // web/admin surface as local). Its own script signs the framed client in on
441
456
  // a fresh session and forwards deep links on a returning one, so both a
442
457
  // fresh sign-in and a restored session land in the same place.
458
+ if (state === 'leaving') return;
443
459
  if (state === 'signed-in' || state === 'restored') { location.href = '/admin/client/'; return; }
444
460
  // Back from the pod's login with no account there yet, or with one to
445
461
  // move here: the identity screen, once the form below is wired.