fedipod 1.44.0 → 1.47.0

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.
Files changed (42) hide show
  1. package/README.md +8 -13
  2. package/architecture.md +5 -2
  3. package/gateway.md +56 -24
  4. package/gui.md +3 -2
  5. package/lib/client/c2s.mjs +23 -74
  6. package/lib/client/masto/index.mjs +5 -4
  7. package/lib/client/masto/render.mjs +4 -1
  8. package/lib/core/intake/activities.mjs +8 -9
  9. package/lib/core/intake/counts.mjs +143 -0
  10. package/lib/core/intake/index.mjs +12 -1
  11. package/lib/core/intake/received.mjs +78 -0
  12. package/lib/core/publisher/notes.mjs +4 -1
  13. package/lib/core/social.mjs +2 -0
  14. package/lib/core/storage.mjs +54 -5
  15. package/lib/core/wire.mjs +16 -5
  16. package/lib/gateway/account-agent.mjs +20 -4
  17. package/lib/gateway/copy.mjs +95 -5
  18. package/lib/gateway/front-core.mjs +18 -9
  19. package/lib/gateway/gateway-core.mjs +46 -14
  20. package/lib/gateway/held-mail.mjs +11 -4
  21. package/lib/gateway/httpsig.mjs +6 -1
  22. package/lib/gateway/keeper.mjs +9 -6
  23. package/lib/gateway/masto-gateway.mjs +140 -40
  24. package/lib/gateway/private-read.mjs +98 -0
  25. package/lib/gateway/quiet.mjs +4 -3
  26. package/lib/gateway/state-api.mjs +73 -18
  27. package/lib/pod/notes.mjs +25 -0
  28. package/lib/pod/urls.mjs +3 -0
  29. package/lib/server/embed.mjs +25 -0
  30. package/lib/surface/routes/owner.mjs +2 -2
  31. package/package.json +2 -2
  32. package/web/admin/gateway.js +8 -24
  33. package/web/admin/index.html +0 -16
  34. package/web/admin/record.js +1 -2
  35. package/web/admin/upkeep.js +1 -5
  36. package/web/app/admin-facade.mjs +1 -1
  37. package/web/app/agent.mjs +27 -10
  38. package/web/app/copy-mode.mjs +98 -13
  39. package/web/app/dist/boot.js +12 -3
  40. package/web/app/dist/boot.js.map +2 -2
  41. package/web/app/dist/sw.js +582 -282
  42. package/web/app/dist/sw.js.map +4 -4
@@ -0,0 +1,78 @@
1
+ // received.mjs — the inbox as its owner reads it (ActivityPub §5.2): every
2
+ // delivery the account accepted, kept on the pod as a paged OrderedCollection
3
+ // in the private container, beside the archive that keeps each delivery's
4
+ // bytes. The door and the agent's own /ap/inbox serve these documents.
5
+ //
6
+ // Pages fill from the oldest end, PAGE_SIZE to a page, and a full page is
7
+ // never written again: an accepted delivery rewrites the newest page and the
8
+ // small head, and nothing else. Activities ride inline, so a reader gets what
9
+ // arrived without a fetch per item — a Like or a Follow has no address its
10
+ // origin answers anyway. Past ITEM_MAX_CHARS the item is the activity's id
11
+ // alone, so one enormous delivery cannot make a page of them enormous.
12
+ //
13
+ // §5.2 also says an activity delivered twice is listed once. The archive
14
+ // already collapses identical bytes; this keeps the last SEEN_MAX ids and
15
+ // drops a repeat that arrived with different addressing.
16
+ //
17
+ // Written only while the archive is on — an owner who chose not to keep what
18
+ // arrives keeps nothing here either — and only once the private container is
19
+ // proved private, the bar every owner-only document clears. Recorded meanwhile,
20
+ // and published the first time the bar is met.
21
+
22
+ import * as wire from '../wire.mjs';
23
+ import * as collection from '../../pod/collection.mjs';
24
+
25
+ export const PAGE_SIZE = wire.FOLLOWERS_PAGE_SIZE;
26
+ export const SEEN_MAX = 500;
27
+ export const ITEM_MAX_CHARS = 32 * 1024;
28
+ // Recorded but unpublished items are held in state; past this many the oldest
29
+ // are let go (the archive still has them) rather than growing state forever.
30
+ const HELD_MAX = PAGE_SIZE * 5;
31
+ const RECEIVED = 'received.json';
32
+
33
+ const fresh = () => ({ sealed: 0, open: [], total: 0, seen: [] });
34
+
35
+ const keeps = (intake) => !!intake.archive && !!intake.urls?.ownInbox
36
+ && intake.store.getConfig?.()?.archiveInbox !== false;
37
+
38
+ /** Record one accepted delivery. False when it was a repeat, or nothing is kept. */
39
+ export function recordReceived(intake, activity) {
40
+ if (!keeps(intake) || !activity || typeof activity !== 'object') return false;
41
+ const st = { ...fresh(), ...intake.store.read(RECEIVED, {}) };
42
+ const id = typeof activity.id === 'string' ? activity.id : null;
43
+ if (id && st.seen.includes(id)) return false;
44
+ st.open.push(id && JSON.stringify(activity).length > ITEM_MAX_CHARS ? id : activity);
45
+ if (st.open.length > HELD_MAX) st.open.splice(0, st.open.length - HELD_MAX);
46
+ st.total += 1;
47
+ if (id) {
48
+ st.seen.push(id);
49
+ if (st.seen.length > SEEN_MAX) st.seen.splice(0, st.seen.length - SEEN_MAX);
50
+ }
51
+ intake.store.write(RECEIVED, st);
52
+ intake._receivedDirty = true;
53
+ return true;
54
+ }
55
+
56
+ /** Write what was recorded since the last publish: the newest page(s) and the head. */
57
+ export async function publishReceived(intake) {
58
+ if (!intake._receivedDirty || !keeps(intake)) return;
59
+ const ready = intake.publisher?.privateReady;
60
+ if (typeof ready === 'function' && await ready.call(intake.publisher) !== true) return;
61
+ const { remote, urls } = intake;
62
+ const st = { ...fresh(), ...intake.store.read(RECEIVED, {}) };
63
+ // Every full page first (several when the bar was met late), then the open one.
64
+ do {
65
+ const n = st.sealed + 1;
66
+ const items = st.open.slice(0, PAGE_SIZE);
67
+ await collection.writePage(remote, wire.followersPageId(urls.ownInbox, n),
68
+ wire.followersPage(urls.ownInbox, n, items, n));
69
+ if (items.length < PAGE_SIZE) break;
70
+ st.sealed = n;
71
+ st.open = st.open.slice(PAGE_SIZE);
72
+ intake.store.write(RECEIVED, st);
73
+ } while (st.open.length);
74
+ const pages = Math.max(1, st.sealed + (st.open.length ? 1 : 0));
75
+ await collection.writeHead(remote, urls.ownInbox, wire.followersHead(urls.ownInbox, st.total, pages));
76
+ intake.store.write(RECEIVED, st);
77
+ intake._receivedDirty = false;
78
+ }
@@ -7,6 +7,7 @@ import * as wire from '../wire.mjs';
7
7
  import * as wireQuotes from '../wire-quotes.mjs';
8
8
  import * as containers from '../../pod/containers.mjs';
9
9
  import * as podNotes from '../../pod/notes.mjs';
10
+ import { totalsFor } from '../intake/counts.mjs';
10
11
 
11
12
  // Media container on the remote pod — public-Read like notes, created lazily
12
13
  // at first upload (idempotent; the flag only saves round-trips).
@@ -177,7 +178,7 @@ export async function noteToSelf(publisher, text) {
177
178
  // What the object is, who made it and when, where it lives and who it is
178
179
  // addressed to stay as they are, and so do a poll's choices and its count.
179
180
  const FIXED = new Set(['@context', 'id', 'type', 'attributedTo', 'published', 'to', 'cc', 'bto', 'bcc',
180
- 'audience', 'oneOf', 'anyOf', 'endTime', 'closed', 'votersCount', 'replies']);
181
+ 'audience', 'oneOf', 'anyOf', 'endTime', 'closed', 'votersCount', 'replies', 'likes', 'shares']);
181
182
  export async function updateObject(publisher, s, patch, { updated = new Date().toISOString() } = {}) {
182
183
  const { urls } = publisher;
183
184
  const doc = await podNotes.read(publisher.remote, s.noteId);
@@ -485,6 +486,7 @@ export async function updateNote(publisher, s, { content, spoilerText = null, se
485
486
  summary: spoilerText, sensitive: sensitive ?? !!s.sensitive, updated, container,
486
487
  also: [...new Set([...(s.named || []), ...(reply ? [reply.actor] : [])])],
487
488
  quote: rowQuote(s),
489
+ ...totalsFor(publisher.store, s.noteId),
488
490
  });
489
491
  await podNotes.write(publisher.remote, note.id, note);
490
492
  await podNotes.writeCreate(publisher.remote, wire.createActivityId(note.id), wire.createActivity(note, urls));
@@ -537,6 +539,7 @@ async function restateNote(publisher, s, { quote }) {
537
539
  summary: s.spoiler || null, sensitive: !!s.sensitive, updated: s.editedAt || null, container,
538
540
  also: [...(reply ? [reply.actor] : []), ...(s.quoteRequest?.actor ? [s.quoteRequest.actor] : [])],
539
541
  quote,
542
+ ...totalsFor(publisher.store, s.noteId),
540
543
  });
541
544
  await podNotes.write(publisher.remote, note.id, note);
542
545
  await podNotes.writeCreate(publisher.remote, wire.createActivityId(note.id), wire.createActivity(note, urls));
@@ -3,6 +3,7 @@
3
3
  // the activity and mirrors state into the store; the store keeps the
4
4
  // activity a later Undo must name.
5
5
 
6
+ import { forgetCounts } from './intake/counts.mjs';
6
7
  import crypto from 'node:crypto';
7
8
  import * as wire from './wire.mjs';
8
9
  import * as podNotes from '../pod/notes.mjs';
@@ -424,6 +425,7 @@ export async function deleteNote(agent, s) {
424
425
  }
425
426
  // Empty, and referenced by nothing once the note is gone.
426
427
  await podNotes.dropReplies(agent.remote, wire.repliesId(s.noteId));
428
+ forgetCounts(agent.store, s.noteId);
427
429
 
428
430
  // The Bluesky mirror goes with the post. Best-effort: a mirror the far side
429
431
  // already lost is not a reason to keep the post here.
@@ -177,21 +177,31 @@ export { podOnly };
177
177
  * An account's state kept at its gateway, reached through the gateway's state
178
178
  * API (lib/gateway/state-api.mjs) with the token it handed out. `holder` is
179
179
  * this agent's lease id: the gateway takes a write only from the lease's
180
- * holder, and `onRefused` hears when it would not. What stays on the pod is
181
- * read and written on `pod`.
180
+ * holder, and `onRefused` hears when it would not. `onGone` hears when the
181
+ * gateway keeps no copy of the account any more and will make none. What
182
+ * stays on the pod is read and written on `pod`.
182
183
  */
183
184
  export class StateApiStorage {
184
- constructor(base, { fetchImpl = globalThis.fetch, token, holder, pod = null, onRefused = null }) {
185
+ constructor(base, { fetchImpl = globalThis.fetch, token, holder, pod = null, onRefused = null, onGone = null }) {
185
186
  this.base = slash(base);
186
187
  this.fetchImpl = fetchImpl;
187
188
  this.token = token;
188
189
  this.holder = holder;
189
190
  this.pod = pod;
190
191
  this.onRefused = onRefused;
192
+ this.onGone = onGone;
191
193
  }
192
194
 
193
195
  get kind() { return 'copy'; }
194
196
 
197
+ async _gone(res) {
198
+ if (res.status !== 404 || !this.onGone) return false;
199
+ const said = await res.clone().json().catch(() => null);
200
+ if (!said?.gone) return false;
201
+ this.onGone();
202
+ return true;
203
+ }
204
+
195
205
  _ask(p, init = {}) {
196
206
  return this.fetchImpl(this.base + encodeURIComponent(p), {
197
207
  ...init, headers: { authorization: `Bearer ${this.token}`, ...(init.headers || {}) },
@@ -202,7 +212,7 @@ export class StateApiStorage {
202
212
  if (sub) return { notModified: false, names: [], etag: null };
203
213
  const res = await this._ask('', { headers: etag ? { 'if-none-match': etag } : {} });
204
214
  if (res.status === 304) return { notModified: true, names: null, etag };
205
- if (res.status >= 400) throw new Error(`the account's copy at the gateway is unreadable (HTTP ${res.status})`);
215
+ if (res.status >= 400) { await this._gone(res); throw new Error(`the account's copy at the gateway is unreadable (HTTP ${res.status})`); }
206
216
  const { names } = await res.json();
207
217
  return { notModified: false, names, etag: res.headers.get('etag') };
208
218
  }
@@ -211,7 +221,7 @@ export class StateApiStorage {
211
221
  if (podOnly(p)) return this.pod ? this.pod.read(p, opts) : { ok: false, notModified: false, status: 404, body: null, etag: null };
212
222
  const res = await this._ask(p, { headers: opts.etag ? { 'if-none-match': opts.etag } : {} });
213
223
  if (res.status === 304) return { ok: true, notModified: true, status: 304, body: null, etag: opts.etag };
214
- if (res.status >= 400) return { ok: false, notModified: false, status: res.status, body: null, etag: null };
224
+ if (res.status >= 400) { await this._gone(res); return { ok: false, notModified: false, status: res.status, body: null, etag: null }; }
215
225
  return { ok: true, notModified: false, status: res.status, body: await res.text(), etag: res.headers.get('etag') };
216
226
  }
217
227
 
@@ -221,6 +231,7 @@ export class StateApiStorage {
221
231
  const res = await this._ask(p, { method: 'PUT', headers: { 'content-type': contentType, 'x-fedipod-holder': this.holder }, body });
222
232
  if (res.status < 400) return { ok: true, retry: false, why: '' };
223
233
  if (res.status === 409) { this.onRefused?.(); return { ok: false, retry: false, why: 'another agent holds this account now', lost: true }; }
234
+ if (await this._gone(res)) return { ok: false, retry: false, why: 'the gateway keeps no copy of this account now', lost: true };
224
235
  return { ok: false, retry: res.status >= 500 || res.status === 429, why: `HTTP ${res.status}`, retryAfterMs: 0 };
225
236
  } catch (e) {
226
237
  return { ok: false, retry: true, why: e.message, retryAfterMs: 0 };
@@ -232,11 +243,49 @@ export class StateApiStorage {
232
243
  try {
233
244
  const res = await this._ask(p, { method: 'DELETE', headers: { 'x-fedipod-holder': this.holder } });
234
245
  if (res.status === 409) { this.onRefused?.(); return false; }
246
+ if (await this._gone(res)) return false;
235
247
  return res.status < 400 || res.status === 404;
236
248
  } catch { return false; }
237
249
  }
238
250
  }
239
251
 
252
+ /**
253
+ * A pod's state container that more than one agent may act on: the owner's
254
+ * browser, and the gateway acting for an app while the hold is off. With a
255
+ * `fence` set, every write first asks it whether this agent still holds the
256
+ * account, and a "no" is refused and told to `onRefused`, as the gateway's
257
+ * copy refuses a write from anyone but its lease's holder. Without one, it is
258
+ * the storage it wraps.
259
+ */
260
+ export class FencedStorage {
261
+ constructor(inner, { fence = null, onRefused = null } = {}) {
262
+ this.inner = inner;
263
+ this.fence = fence;
264
+ this.onRefused = onRefused;
265
+ }
266
+
267
+ get kind() { return this.inner.kind; }
268
+ get base() { return this.inner.base; }
269
+ list(sub, opts) { return this.inner.list(sub, opts); }
270
+ read(p, opts) { return this.inner.read(p, opts); }
271
+
272
+ async _refused() {
273
+ if (!this.fence || await this.fence().catch(() => true)) return false;
274
+ this.onRefused?.();
275
+ return true;
276
+ }
277
+
278
+ async write(p, body, contentType) {
279
+ if (await this._refused()) return { ok: false, retry: false, why: 'another agent holds this account now', lost: true };
280
+ return this.inner.write(p, body, contentType);
281
+ }
282
+
283
+ async remove(p) {
284
+ if (await this._refused()) return false;
285
+ return this.inner.remove(p);
286
+ }
287
+ }
288
+
240
289
  // `file:` (or a bare path) is a directory; anything else is a pod.
241
290
  export function storageFor(base, fetchImpl) {
242
291
  return /^https?:/i.test(base) ? new HttpStorage(base, fetchImpl) : new FileStorage(base);
package/lib/core/wire.mjs CHANGED
@@ -70,13 +70,13 @@ export function jrd({ handle, host, actor, aliases = [], page = null }) {
70
70
  // `kind: 'group'` publishes a Group rather than a Person, which is what makes
71
71
  // Mastodon and Lemmy treat the actor as a community you join.
72
72
  // Whether a follow will be held for the operator instead of accepted on
73
- // arrival. A person's follows wait unless `autoAcceptFollows` says otherwise,
74
- // because a delivery on its own proves nothing about who sent it; a group is
75
- // open unless its operator asked to see joins. The actor document has to say
73
+ // arrival. A person's follows are accepted unless `autoAcceptFollows: false`
74
+ // asks to see them; a group is open unless its operator asked to see joins.
75
+ // The actor document has to say
76
76
  // the same thing, or a remote server shows "Following" for a follow that is
77
77
  // in fact still waiting, and nothing ever tells it otherwise.
78
78
  export function followsNeedApproval(config = {}) {
79
- return !!config.approveJoins || (config.kind !== 'group' && !config.autoAcceptFollows);
79
+ return !!config.approveJoins || (config.kind !== 'group' && config.autoAcceptFollows === false);
80
80
  }
81
81
 
82
82
  export const assertionKeyId = (urls) => urls.actor + '#ed25519-key';
@@ -654,6 +654,16 @@ export function quotePolicy(visibility) {
654
654
  return { canQuote: { automaticApproval: open ? [PUBLIC] : [], manualApproval: [] } };
655
655
  }
656
656
 
657
+ // How many liked a post and how many boosted it (§5.7, §5.8): the totals,
658
+ // with nobody named. Both sections let a collection be filtered for whoever
659
+ // asks, and a total with no names is how Mastodon serves them too.
660
+ export function reactionCounts(likes = 0, shares = 0) {
661
+ return {
662
+ likes: { type: 'Collection', totalItems: Math.max(0, Number(likes) || 0) },
663
+ shares: { type: 'Collection', totalItems: Math.max(0, Number(shares) || 0) },
664
+ };
665
+ }
666
+
657
667
  // `also`: actors addressed without being mentioned — the parent's author on
658
668
  // a reply. In cc but not in tag, so their server receives the post and does
659
669
  // not notify them. A direct post names its audience in the text alone.
@@ -661,7 +671,7 @@ export function quotePolicy(visibility) {
661
671
  // authorization is the quoted author's QuoteAuthorization, once granted.
662
672
  export function noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions = [],
663
673
  visibility = 'public', summary = null, sensitive = false, updated = null, container = null, also = [],
664
- quote = null }) {
674
+ quote = null, likes = 0, shares = 0 }) {
665
675
  const id = (container || urls.notes) + slug;
666
676
  const who = mentions.map(m => m.actor);
667
677
  const extra = visibility === 'direct' ? [] : also.filter(a => a && !who.includes(a));
@@ -676,6 +686,7 @@ export function noteDoc({ urls, slug, content, published, inReplyTo, attachments
676
686
  to: addressed.to,
677
687
  cc: addressed.cc,
678
688
  replies: repliesId(id),
689
+ ...reactionCounts(likes, shares),
679
690
  interactionPolicy: quotePolicy(visibility),
680
691
  };
681
692
  if (quote?.id) {
@@ -4,9 +4,9 @@
4
4
  //
5
5
  // It works with the gateway's own pod identity and nothing else, which the
6
6
  // owner's app named in the rules on the account's folder. The account's state
7
- // is its copy at the gateway when there is one (copy.mjs), and the pod when
8
- // there is not. The signing key is read from the pod when it is needed, never
9
- // kept, never made.
7
+ // is its copy at the gateway when there is one (copy.mjs), made from the pod
8
+ // while the hold is on, and the pod when the hold is off. The signing key is
9
+ // read from the pod when it is needed, never kept, never made.
10
10
  import crypto from 'node:crypto';
11
11
  import { PodTransport } from '../pod/transport.mjs';
12
12
  import { podBaseOfWebId } from '../pod/urls.mjs';
@@ -19,7 +19,8 @@ import { Publisher } from '../core/publisher/index.mjs';
19
19
  import { Intake } from '../core/intake/index.mjs';
20
20
  import { C2S } from '../client/c2s.mjs';
21
21
  import { keeperSession } from './keeper-session.mjs';
22
- import { CopyStorage, copyMeta, copyLease, keptBefore, GATEWAY_HOLDER } from './copy.mjs';
22
+ import { CopyStorage, copyMeta, copyLease, keptBefore, holdOn, GATEWAY_HOLDER } from './copy.mjs';
23
+ import { makeCopy } from './state-api.mjs';
23
24
 
24
25
  const RSA_ALG = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
25
26
 
@@ -58,6 +59,21 @@ export async function reachAccount(ctx, handle, rec, { log = console.log, sessio
58
59
  return { remote, podFetch: (u, i) => remote.fetch(u, i), pod, root, urls: apUrls(pod, root), inCopy };
59
60
  }
60
61
 
62
+ /**
63
+ * Where this piece of work reads and writes the account, decided by a caller
64
+ * holding lockCopy, so the round cannot delete the copy in between: a copy
65
+ * that is here is used; none, while the hold is on, is made from the pod; with
66
+ * the hold off, the pod. Sets `at.inCopy`; returns { ok } or { ok: false,
67
+ * status, why }.
68
+ */
69
+ export async function settle(ctx, handle, rec, at, { log = console.log } = {}) {
70
+ at.inCopy = !!(ctx.copyKv && await copyMeta(ctx.copyKv, handle));
71
+ if (at.inCopy || !ctx.copyKv || !holdOn(ctx)) return { ok: true };
72
+ const made = await makeCopy(ctx, handle, rec, log);
73
+ at.inCopy = made.ok;
74
+ return made;
75
+ }
76
+
61
77
  /**
62
78
  * The account's state store and lease, as `holder`: over the copy when there
63
79
  * is one, else over the pod.
@@ -3,11 +3,17 @@
3
3
  // While the gateway keeps an account running, the account's state documents
4
4
  // (everything under ap-state/) live here, and every agent works from this copy:
5
5
  // the owner's browser (through the state API, state-api.mjs), the keeper and
6
- // the gateway's Mastodon API (in process). The pod is written from it every
7
- // fifteen minutes (flushCopy), and at once when the copy is given up
8
- // (dropCopy). What only the pod may hold never comes here: the signing key,
9
- // the pod's own lease document, and the passwords and tokens of accounts on
10
- // other servers.
6
+ // the gateway's Mastodon API (in process). What only the pod may hold never
7
+ // comes here: the signing key, the pod's own lease document, and the passwords
8
+ // and tokens of accounts on other servers.
9
+ //
10
+ // The hold: a person's copy is made from the pod when something needs it, and
11
+ // every fifteen minutes, whatever is happening, it is written to the pod and
12
+ // deleted (holdOver); the next use makes it again. So nothing private stays
13
+ // here longer than that. A gateway's admin may turn the hold off
14
+ // (FEDIPOD_HOLD=off): then no copy is made at all, and everything works on the
15
+ // pod directly. A forum's copy is kept all the time, and written to its pod
16
+ // every fifteen minutes (flushCopy), as before.
11
17
  //
12
18
  // The lease that decides which agent acts lives in the copy too, and the copy
13
19
  // is what enforces it: a state document is written only by the lease's holder.
@@ -89,6 +95,30 @@ const readJson = async (kv, key) => {
89
95
  export const keptNow = (ctx, rec) => !!(rec?.keeper && ctx.keeperWebId && (rec.keeper.webId || ctx.keeperWebId) === ctx.keeperWebId);
90
96
  export const keptBefore = (ctx, rec) => !!(rec?.keeper?.webId && ctx.keeperWebId && rec.keeper.webId !== ctx.keeperWebId);
91
97
 
98
+ // A person's account, as against a forum or one of its categories.
99
+ export const isPersonal = (rec) => !!rec && (rec.kind || 'person') === 'person' && !rec.forum;
100
+
101
+ // The admin's setting: the hold is on unless FEDIPOD_HOLD says off.
102
+ export const holdFrom = (v) => !/^(off|no|false|0)$/iu.test(String(v ?? '').trim());
103
+ export const holdOn = (ctx) => ctx?.hold !== false;
104
+
105
+ // While the round writes a copy to the pod and deletes it: the state API asks
106
+ // its callers to try again in a moment, and the gateway's readers wait.
107
+ const CLOSING_MS = 2 * 60_000;
108
+ export async function closing(kv, h) {
109
+ const at = Number((await kv.get(`${account(h)}/closing`))?.text || 0);
110
+ return !!at && Date.now() - at < CLOSING_MS;
111
+ }
112
+ /** Wait, up to `waitMs`, for a copy being written and deleted to be gone. */
113
+ export async function waitOutClosing(kv, h, { waitMs = 10_000 } = {}) {
114
+ const until = Date.now() + waitMs;
115
+ while (await closing(kv, h)) {
116
+ if (Date.now() > until) return false;
117
+ await new Promise((r) => setTimeout(r, 250));
118
+ }
119
+ return true;
120
+ }
121
+
92
122
  /**
93
123
  * Forget a copy its owner has written to the pod themselves: no write-back, no
94
124
  * pod lease to free (the owner took it). Asked only by the lease's holder.
@@ -135,6 +165,12 @@ export function copyLease(kv, h, { id, log = () => {} }) {
135
165
  return new Lease({ url: `copy:${account(h)}/lease`, fetchImpl: kvDocFetch(kv, `${h}/lease`), log, id });
136
166
  }
137
167
 
168
+ /** Whether a browser, as against the gateway, holds the copy's lease now. */
169
+ export async function heldByBrowser(kv, h) {
170
+ const { doc } = await readJson(kv, `${account(h)}/lease`);
171
+ return !!doc && doc.holder !== GATEWAY_HOLDER && Date.now() < doc.expiresAt;
172
+ }
173
+
138
174
  /** Whether `holder` holds the copy's lease now. */
139
175
  export async function holds(kv, h, holder) {
140
176
  const { doc } = await readJson(kv, `${account(h)}/lease`);
@@ -275,10 +311,14 @@ export async function fillCopy(kv, h, { pod, podFetch, stateUrl, webId = null, l
275
311
  }
276
312
  const undo = async (why) => {
277
313
  for (const b of await kv.list(`${h}/d/`).catch(() => [])) await kv.delete(b.key).catch(() => {});
314
+ await kv.delete(`_copies/${h}`).catch(() => {});
278
315
  await podLease.release().catch(() => {});
279
316
  return { ok: false, why };
280
317
  };
281
318
  try {
319
+ // Listed from the start, so a making cut off part way is found by the
320
+ // round and its documents deleted (clearLeftover).
321
+ await kv.set(`_copies/${h}`, String(Date.now()));
282
322
  // Whatever an interrupted give-up left behind is not this copy.
283
323
  for (const b of await kv.list(`${h}/d/`)) await kv.delete(b.key);
284
324
  const listing = await pod.list('');
@@ -388,3 +428,53 @@ export async function dropCopy(kv, h, { pod, podFetch, stateUrl, log = () => {}
388
428
  log(`copy @${h}: given up; the pod holds everything`);
389
429
  return { ok: true };
390
430
  }
431
+
432
+ /**
433
+ * The end of the hold, for a person's copy: everything written to the pod, the
434
+ * pod's lease freed, and the copy deleted. Unlike giving the copy up
435
+ * (dropCopy), the copy's lease stays, so the browser holding it goes on holding
436
+ * it in the copy made next. Asked by the round, which holds lockCopy. Writes
437
+ * that reach the state API meanwhile are asked to come again (closing), and
438
+ * the next one finds the copy made again from the pod. Returns { ok } or
439
+ * { ok: false, why }; nothing is deleted unless the pod has it all.
440
+ */
441
+ export async function holdOver(kv, h, { pod, podFetch, stateUrl, log = () => {} }) {
442
+ const meta = await copyMeta(kv, h);
443
+ if (!meta) return { ok: true, already: true };
444
+ await kv.set(`${account(h)}/closing`, String(Date.now()));
445
+ try {
446
+ // Again, twice at most, for a write that landed while the first went: the
447
+ // marker stops any more coming.
448
+ let behind = [];
449
+ for (let i = 0; i < 3; i++) {
450
+ await flushCopy(kv, h, { pod, log });
451
+ const { doc: flushed } = await readJson(kv, `${h}/flushed`);
452
+ behind = (await kv.list(`${h}/d/`)).filter((b) => flushed?.[b.key.slice(`${h}/d/`.length)]?.etag !== b.etag);
453
+ if (!behind.length) break;
454
+ }
455
+ if (behind.length) return { ok: false, why: `${behind.length} document(s) could not be written to the pod` };
456
+ const podLease = new Lease({ url: (stateUrl || meta.stateUrl) + 'lease.json', fetchImpl: podFetch, log, id: POD_HOLDER });
457
+ try { await podLease.write({ holder: POD_HOLDER, expiresAt: 0 }); } catch (e) {
458
+ return { ok: false, why: `the pod's lease could not be freed (${e.message})` };
459
+ }
460
+ // The copy is gone the moment its meta is. Its listing goes last, so a
461
+ // delete cut off part way is found and finished by the next round
462
+ // (clearLeftover).
463
+ await kv.delete(`${h}/meta`);
464
+ await kv.delete(`${h}/flushed`);
465
+ for (const b of await kv.list(`${h}/d/`)) await kv.delete(b.key);
466
+ await kv.delete(`_copies/${h}`);
467
+ log(`copy @${h}: written to the pod and deleted (the hold)`);
468
+ return { ok: true };
469
+ } finally { await kv.delete(`${h}/closing`).catch(() => {}); }
470
+ }
471
+
472
+ /** A copy listed with no meta is one whose delete was cut off: its documents go. */
473
+ export async function clearLeftover(kv, h, { log = () => {} } = {}) {
474
+ if (await copyMeta(kv, h)) return false;
475
+ const left = await kv.list(`${account(h)}/d/`);
476
+ for (const b of left) await kv.delete(b.key);
477
+ await kv.delete(`_copies/${h}`);
478
+ if (left.length) log(`copy @${h}: ${left.length} document(s) left by a cut-off delete, deleted`);
479
+ return true;
480
+ }
@@ -23,11 +23,12 @@ 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, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi } from './held-mail.mjs';
26
+ import { holdsMail, mayBeAway, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi } from './held-mail.mjs';
27
27
  import { routeNoticesApi } from './notices.mjs';
28
28
  import { withSecurityHeaders } from './headers.mjs';
29
29
  import { podTokenVerifier } from './caches.mjs';
30
30
  import { claimedWebId } from './token-claims.mjs';
31
+ import { answerPrivateRead } from './private-read.mjs';
31
32
  import { announced, overLimit, relayOne } from './relay-extras.mjs';
32
33
 
33
34
  // The one WebFinger document, spelled out here rather than imported from
@@ -810,20 +811,29 @@ async function route(request, ctx) {
810
811
  'access-control-max-age': '86400',
811
812
  };
812
813
  if (request.method === 'OPTIONS') return { status: 204, headers: { ...doorCors, allow: 'POST, OPTIONS' }, body: null };
814
+ // The owner, signed in, reads the inbox as a collection (§5.2): the drain
815
+ // keeps it on the pod, under the owner's own rule. The token is only
816
+ // read, never verified, as at the outbox door: the pod checks it.
817
+ if ((request.method === 'GET' || request.method === 'HEAD') && request.headers.get('authorization')) {
818
+ const webid = claimedWebId(request);
819
+ if (webid && (rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome))) {
820
+ return { status: 303, headers: { ...doorCors, location: rec.podHome + 'ap/private/inbox', 'cache-control': 'no-store' }, body: '' };
821
+ }
822
+ }
813
823
  if (request.method !== 'POST') return { status: 405, headers: {}, body: '' };
814
824
  if (movedBase) { console.log(`door @${up.handle}: delivery → 410 (moved)`); return gone(doorCors); }
815
825
  if (closed) { console.log(`door @${up.handle}: delivery → 410 (closed)`); return gone(doorCors, CLOSED); }
816
826
  const policy = await policyFor(rec, ctx.fetchImpl || fetch);
817
827
  const standing = await accountState(ctx, up.handle, rec);
818
- // A browser account's mail waits here while its app is closed (held-mail.mjs).
819
- const held = holdsMail(ctx, rec) && !(await isPresent(ctx, up.handle));
828
+ // A browser account's mail waits here while its app is closed (held-mail.mjs), unless the hold is off.
829
+ const away = mayBeAway(ctx, rec) && !(await isPresent(ctx, up.handle)); const held = away && holdsMail(ctx, rec);
820
830
  const { status, reason, content, bytes, type, notifies, name, raw, receipt } = await handleDelivery(request, identFor(rec, policy),
821
831
  { podPut: held ? holdingPut(ctx, up.handle, rec) : (u, b, ct) => ctx.podPut(up.handle, u, b, ct),
822
- fetchImpl: ctx.fetchImpl, paused: standing.paused });
832
+ fetchImpl: ctx.fetchImpl, paused: standing.paused, origin: ctx.frontOrigin || null });
823
833
  // One line per delivery, so "did it arrive at the door" has an answer.
824
834
  console.log(`door @${up.handle}: delivery → ${status} (${reason}${held && status === 202 ? ', held' : ''})`);
825
835
  if (status === 202 && content) await noteReceived(ctx, up.handle, rec, bytes);
826
- if (held && status === 202) await afterHeld(ctx, up.handle, rec, { type, notifies });
836
+ if (away && status === 202) await afterHeld(ctx, up.handle, rec, { type, notifies });
827
837
  // A forum fedipod.net keeps: the delivery is placed now, in this request,
828
838
  // and the run that carries it is started. Whatever could not be placed
829
839
  // here is still in the inbox for that run.
@@ -911,10 +921,9 @@ async function route(request, ctx) {
911
921
  const podTarget = rec.podHome + up.rest;
912
922
  // The owner's own documents — the full outbox, the liked list, the pending
913
923
  // lists — are read at the pod, which decides who may; never held here,
914
- // where one reader's answer would be served to the next.
915
- if (up.rest.startsWith('ap/private/')) {
916
- return { status: 303, headers: { ...open, location: podTarget, 'cache-control': 'no-store' }, body: '' };
917
- }
924
+ // where one reader's answer would be served to the next. A signed read of
925
+ // a private post by a server it was sent to is answered (private-read.mjs).
926
+ if (up.rest.startsWith('ap/private/')) return answerPrivateRead(ctx, rec, { request, handle: up.handle, podTarget, base, open, swap });
918
927
  // Media stays on the pod (lib/pod/urls.mjs keeps `media` off the front), but
919
928
  // the id rewrite below turns media links onto the front like every other
920
929
  // pod url in a document. Answer those by pointing at the pod: bytes are not
@@ -91,12 +91,34 @@ export function notifies(activity, ident) {
91
91
  // Returns { status, reason } — the adapter turns it into an HTTP response —
92
92
  // and, for a delivery that reached the pod, `content` (whether it was
93
93
  // content rather than control) and `bytes`, so the caller can keep count.
94
- export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES, paused = false } = {}) {
95
- // Read the body once from a clone; the original, unconsumed, goes to the
96
- // verifier (which needs the body for the Digest check).
97
- let raw;
98
- try { raw = await request.clone().text(); } catch { return { status: 400, reason: 'unreadable body' }; }
99
- if (Buffer.byteLength(raw) > maxBytes) return { status: 413, reason: 'too large' };
94
+ // The request as the sender signed it. A sender signs the host it delivered
95
+ // to and digests the bytes it sent; what reaches this code has been through
96
+ // an edge that may have rewritten the Host header, the URL's host or both
97
+ // (Netlify does), and a verifier that trusts those fails every honest
98
+ // delivery. So the verifier gets a request rebuilt from the bytes read here,
99
+ // at the door's own origin when the caller names it, else at the host a
100
+ // proxy says it forwarded for, else at the URL's own.
101
+ export function asSigned(request, bytes, origin) {
102
+ const u = new URL(request.url);
103
+ let host = null;
104
+ if (origin) { try { const o = new URL(origin); u.protocol = o.protocol; host = o.host; } catch { /* not an origin: the URL's own */ } }
105
+ host = host || request.headers.get('x-forwarded-host')?.split(',')[0].trim() || u.host;
106
+ u.host = host;
107
+ const headers = new Headers(request.headers);
108
+ headers.set('host', host);
109
+ // A GET or HEAD carries no body, and a Request refuses one (a signed read: private-read.mjs).
110
+ const bodiless = request.method === 'GET' || request.method === 'HEAD';
111
+ return new Request(u.href, { method: request.method, headers, ...(bodiless ? {} : { body: bytes }) });
112
+ }
113
+
114
+ // `origin` is the door's own, the one senders address (see asSigned).
115
+ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES, paused = false, origin = null } = {}) {
116
+ // The body, once, as the bytes the sender digested; the text of it is what
117
+ // is filed and hashed, as before.
118
+ let bytes, raw;
119
+ try { bytes = new Uint8Array(await request.clone().arrayBuffer()); raw = new TextDecoder().decode(bytes); }
120
+ catch { return { status: 400, reason: 'unreadable body' }; }
121
+ if (bytes.byteLength > maxBytes) return { status: 413, reason: 'too large' };
100
122
 
101
123
  let activity;
102
124
  try { activity = JSON.parse(raw); } catch { return { status: 400, reason: 'unparsable JSON' }; }
@@ -114,15 +136,25 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
114
136
 
115
137
  // The sender's key, kept between deliveries: a server pushing a hundred
116
138
  // items is asked for its key once (caches.mjs).
117
- const v = await verifyHttpSignature(request, {
139
+ const v = await verifyHttpSignature(asSigned(request, bytes, origin), {
118
140
  documentLoader: makeSafeLoader({ fetchImpl }), keyCache: senderKeys,
119
141
  });
120
- // A present-but-invalid signature is a forgery — dropped here, so it never
121
- // reaches the pod (today it would, drain, and die unapplied). An absent or
122
- // unfetchable-key signature is NOT dropped: it forwards unverified and the
123
- // drain's verify-by-dereference still stands behind it.
142
+ // A signature that does not verify is NOT dropped (it was, 2026-08-20 to
143
+ // 2026-09-28, and for a stretch of that every honest Mastodon delivery was
144
+ // dropped with it, silently, because of what the edge had done to the
145
+ // request). It forwards unverified, like an absent or unfetchable-key
146
+ // signature, and the drain's verify-by-dereference decides; the log says
147
+ // why it failed and what the request looked like, so the next such
148
+ // stretch is visible.
149
+ let failed = null;
124
150
  if (v.verified === false && v.reason === 'bad-signature') {
125
- return { status: 202, reason: 'forged signature' };
151
+ const has = (h) => (request.headers.has(h) ? 'yes' : 'no');
152
+ const fwd = request.headers.get('x-forwarded-host');
153
+ // The sender's signature parameters — which components it signed, its
154
+ // key id and algorithm — without the signature value itself.
155
+ const params = request.headers.get('signature-input')
156
+ || (request.headers.get('signature') || '').replace(/signature="[^"]*"/u, 'signature="…"');
157
+ failed = `signature ${v.why} (${v.method}); host ${request.headers.get('host') || 'none'}${fwd ? ` forwarded-for ${fwd}` : ''}, date ${has('date')}, digest ${has('digest') === 'yes' || has('content-digest') === 'yes' ? 'yes' : 'no'}; ${params.slice(0, 300)}`;
126
158
  }
127
159
 
128
160
  // No per-user secret on record = no receipt: the delivery still filters and
@@ -137,8 +169,8 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
137
169
  // holding any state.
138
170
  if (!okA) return { status: 502, reason: 'pod inbox write failed' };
139
171
  if (receipt) await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
140
- return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified', content, bytes: Buffer.byteLength(raw),
141
- type: activity.type, notifies: notifies(activity, ident), name: hash, raw, receipt };
172
+ return { status: 202, reason: v.verified ? 'verified' : failed ? `buffered-unverified: ${failed}` : 'buffered-unverified',
173
+ content, bytes: bytes.byteLength, type: activity.type, notifies: notifies(activity, ident), name: hash, raw, receipt };
142
174
  }
143
175
 
144
176
  // The outbox door: the owner's own post, taken on their behalf.