fedipod 1.50.0 → 1.55.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.
Files changed (44) hide show
  1. package/README.md +2 -1
  2. package/gateway.md +25 -0
  3. package/gui.md +11 -3
  4. package/lib/client/masto/render.mjs +11 -4
  5. package/lib/client/masto/timelines.mjs +5 -0
  6. package/lib/connections/acctfeed.mjs +4 -1
  7. package/lib/connections/tagfeed.mjs +4 -1
  8. package/lib/core/graphview.mjs +9 -1
  9. package/lib/core/intake/index.mjs +35 -2
  10. package/lib/core/intake/verify.mjs +3 -1
  11. package/lib/core/pod-only.mjs +4 -2
  12. package/lib/core/publisher/index.mjs +19 -5
  13. package/lib/core/publisher/restore.mjs +1 -0
  14. package/lib/core/store.mjs +27 -6
  15. package/lib/gateway/account-agent.mjs +11 -5
  16. package/lib/gateway/check-letter.mjs +123 -0
  17. package/lib/gateway/connected-feeds.mjs +116 -0
  18. package/lib/gateway/copy.mjs +19 -0
  19. package/lib/gateway/front-core.mjs +5 -3
  20. package/lib/gateway/held-mail.mjs +25 -1
  21. package/lib/gateway/keeper.mjs +2 -1
  22. package/lib/gateway/masto-gateway.mjs +47 -7
  23. package/lib/surface/routes/owner.mjs +7 -4
  24. package/package.json +3 -3
  25. package/scripts/stage-site.mjs +2 -3
  26. package/vendor/sanitize-html.cjs +358 -171
  27. package/web/admin/bar.js +45 -0
  28. package/web/admin/group.js +17 -0
  29. package/web/admin/index.html +12 -8
  30. package/web/admin/record.js +19 -41
  31. package/web/admin/upkeep.js +3 -0
  32. package/web/app/README.md +3 -3
  33. package/web/app/admin-facade.mjs +19 -16
  34. package/web/app/agent.mjs +68 -3
  35. package/web/app/atproto-browser.mjs +28 -22
  36. package/web/app/deliver-relay.mjs +2 -1
  37. package/web/app/dist/boot.js +1 -0
  38. package/web/app/dist/boot.js.map +2 -2
  39. package/web/app/dist/sw.js +601 -273
  40. package/web/app/dist/sw.js.map +4 -4
  41. package/web/app/fediacct-browser.mjs +32 -30
  42. package/web/app/idb-kv.mjs +4 -4
  43. package/web/app/shims/prelude.js +1 -0
  44. package/web/app/sw-src.mjs +44 -3
package/README.md CHANGED
@@ -52,7 +52,8 @@ Akkoma, Pixelfed or Friendica, connect it from the manage page and read it
52
52
  here. Its home timeline and notifications join the feed you already read,
53
53
  interleaved by time, and a post both accounts see appears once. Favouriting,
54
54
  boosting and replying act as the account the post came through. The token it
55
- hands back stays in your browser unless you choose to keep it on your pod.
55
+ hands back is kept on your pod, so the account follows you to any browser you
56
+ sign in from.
56
57
 
57
58
  **Moving in.** Mastodon-format exports of follows, blocks, mutes, lists and
58
59
  domain blocks import from the manage page. Your old account can be listed as
package/gateway.md CHANGED
@@ -240,6 +240,19 @@ An app checks for new posts every minute or so while it is open; there are no
240
240
  live updates. Accounts are made on the fedipod.net front page, with a pod, not
241
241
  from an app.
242
242
 
243
+ **Your connected accounts, with FediPod closed.** FediPod open in a browser
244
+ reads the Mastodon and Bluesky accounts you connected every five minutes.
245
+ While it is closed and an app is in use, fedipod.net reads them instead, at
246
+ most every five minutes, when the app checks your timeline or notifications.
247
+ Their logins are read from your pod for each read and are not kept at
248
+ fedipod.net. An account you have just connected can take up to an hour to be
249
+ read here.
250
+
251
+ **Hashtags.** The hashtags you follow are one list, in FediPod and in an app:
252
+ following or unfollowing one in either changes it in both. FediPod brings in
253
+ the posts for them, while it is open. **hashtag posts in home** on your manage
254
+ page keeps them out of your home timeline, in FediPod and in an app alike.
255
+
243
256
  **Notifications on your phone.** An app that offers notifications can sign up
244
257
  for them here. A mention, a reply, a direct message, a follow, or a like, boost
245
258
  or reaction to your own post then reaches your phone as it arrives, whether
@@ -281,6 +294,18 @@ removes them at `/notices`, signed in as the admin the way the roster is.
281
294
  A notice is a title and plain text: a blank line starts a paragraph, and a
282
295
  web address becomes a link.
283
296
 
297
+ ## When mail does not get through
298
+
299
+ When FediPod cannot read a piece of your mail, a red line across the top of
300
+ its pages says so until you open Show dead letters on your manage page. Your
301
+ phone hears too, at most once an hour, if a Mastodon app on it is signed in at
302
+ the gateway with notifications on.
303
+
304
+ The gateway's admin also gets a test letter every six hours, sent to their own
305
+ account through the same door every server uses. FediPod takes it in and shows
306
+ it nowhere. If FediPod has been open for an hour and the letter has not
307
+ arrived, the admin's phone is told and the red line goes up.
308
+
284
309
  ## What the gateway can see
285
310
 
286
311
  It reads only public data to decide what concerns you: your published
package/gui.md CHANGED
@@ -3,7 +3,7 @@
3
3
  In the browser version at fedipod.net, `manage`, in the bar's account group, opens this
4
4
  same page for your account. The rest of this page describes it as the
5
5
  DeviceAgent serves it; the controls are the same, minus the manual inbox
6
- drain and the local log, which a browser does not have.
6
+ drain, which a browser does not have.
7
7
 
8
8
  Open `https://localhost:8030/` while any agent is running — it forwards you to the agent — then choose `manage` in the bar's account group and select the actor you want from the local actors dropdown.
9
9
  Picking an actor marked "(stopped)" starts its agent, then opens its page.
@@ -48,8 +48,7 @@ its own port.
48
48
 
49
49
  Each identity has a page anyone can open, at `ap/profile.html` under its pod
50
50
  — for example `https://your-pod.example/fedipod/ap/profile.html`
51
- (the `fedipod` container is wherever you put it at setup; the manage page's
52
- "stored at" row says where). It
51
+ (the `fedipod` container is wherever you put it at setup). It
53
52
  shows the name, bio and address, and offers a Follow box: a visitor types
54
53
  their own server and lands on that server's follow screen. Hand out that
55
54
  link, or the `@name@host` address itself, which works in the search box of
@@ -96,6 +95,15 @@ Disconnecting stops the reading and removes the stored token; nothing already
96
95
  posted is touched. If the other server stops accepting the token, the account
97
96
  is marked **sign in again** rather than quietly dropped.
98
97
 
98
+ ## Hashtag posts in your home timeline
99
+
100
+ The posts brought in for the hashtags you follow appear in your home timeline.
101
+ Set **hashtag posts in home** to *hidden* and they appear only on the public
102
+ timeline and on each hashtag's page; they are still brought in. A hashtag post
103
+ that one of your connected accounts also brought in is from someone you follow
104
+ there, and stays. An app already showing them keeps them on screen until it
105
+ reloads.
106
+
99
107
  ## Handling follow requests
100
108
 
101
109
  New followers are accepted automatically. Set **new followers** to *wait for
@@ -10,6 +10,13 @@ import { sanitizeHtml, followsNeedApproval, publicHandle } from '../../core/wire
10
10
  import { profileUrl, postUrl } from '../../connections/bskyfeed.mjs';
11
11
  import { htmlToText } from './body.mjs';
12
12
 
13
+ // Posts stored from 2026-09-13 to 2026-10-10 hold a Mastodon post's text twice,
14
+ // joined by a comma; shown once.
15
+ export const once = (html) => {
16
+ const m = (html.length - 1) / 2;
17
+ return Number.isInteger(m) && html[m] === ',' && html.slice(0, m) === html.slice(m + 1) ? html.slice(0, m) : html;
18
+ };
19
+
13
20
  // 1x1 transparent PNG — placeholder header, and the instance thumbnail. A
14
21
  // banner nobody set should take up no room and show nothing; an avatar is a
15
22
  // different matter (below).
@@ -273,7 +280,7 @@ export function status(api, s, { all, depth = 0 } = {}) {
273
280
  created_at: s.published || new Date().toISOString(),
274
281
  in_reply_to_id: s.inReplyTo ? api.store.idFor(s.inReplyTo) : null,
275
282
  in_reply_to_account_id: null,
276
- sensitive: !!(s.spoiler || s.sensitive), spoiler_text: s.spoiler || '',
283
+ sensitive: !!(s.spoiler || s.sensitive), spoiler_text: once(s.spoiler || ''),
277
284
  visibility: s.visibility || 'public', language: null,
278
285
  edited_at: s.editedAt || null,
279
286
  uri: s.noteId, url: s.link || s.noteId,
@@ -284,7 +291,7 @@ export function status(api, s, { all, depth = 0 } = {}) {
284
291
  favourited: !!s.favourited || (s.sourceAccts || []).some(v => v.favourited),
285
292
  reblogged: !!s.reblogged || (s.sourceAccts || []).some(v => v.reblogged),
286
293
  muted: false, bookmarked: !!s.bookmarked, pinned: !!s.pinned,
287
- content: s.content || '',
294
+ content: once(s.content || ''),
288
295
  reblog: null, application: null,
289
296
  account: api.account(s.actor),
290
297
  media_attachments: (s.attachments || []).map(a => api.mediaJson(a)),
@@ -323,7 +330,7 @@ export function status(api, s, { all, depth = 0 } = {}) {
323
330
  // information it uses to decide anyway.
324
331
  export function filtersFor(api, s) {
325
332
  const now = Date.now();
326
- const hay = `${s.content || ''} ${s.spoiler || ''}`
333
+ const hay = `${once(s.content || '')} ${once(s.spoiler || '')}`
327
334
  .replace(/<[^>]*>/gu, ' ') // the text, not the markup around it
328
335
  .toLowerCase();
329
336
  if (!hay.trim()) return [];
@@ -403,7 +410,7 @@ export function pushNotify(api, n) {
403
410
  return api.push.notify(n, {
404
411
  notification_id: n.id, notification_type: n.type, preferred_locale: 'en',
405
412
  title: `${acct.display_name || acct.acct} ${verbs[n.type] || n.type}`,
406
- body: s ? htmlToText(s.content || '').slice(0, 140) : '',
413
+ body: s ? htmlToText(once(s.content || '')).slice(0, 140) : '',
407
414
  icon: acct.avatar || '',
408
415
  });
409
416
  }
@@ -18,6 +18,10 @@ export async function handle(api, ctx) {
18
18
  if (pathname === '/api/v1/timelines/home' || pathname === '/api/v1/timelines/public'
19
19
  || pathname === '/api/v1/trends/statuses') {
20
20
  const localOnly = pathname === '/api/v1/timelines/public' && url.searchParams.get('local') === 'true';
21
+ // The owner can keep hashtag posts out of Home (`tagsInHome: false`); they
22
+ // stay on the public timeline, their hashtags' pages and trends. One a
23
+ // connected account also brought in is from someone followed there.
24
+ const hideTags = pathname === '/api/v1/timelines/home' && api.store.getConfig?.()?.tagsInHome === false;
21
25
  // The UNFILTERED list, both as the source and as the reply-count corpus:
22
26
  // a reply can be a mention, and mentions are filtered out of timelines
23
27
  // below — counting against the filtered set would undercount them.
@@ -31,6 +35,7 @@ export async function handle(api, ctx) {
31
35
  .filter(s => s.kind !== 'remote' && s.kind !== 'mention')
32
36
  .filter(s => !s.direct && s.visibility !== 'direct')
33
37
  .filter(s => !muted.has(s.actor) && !muted.has(s.via))
38
+ .filter(s => !hideTags || s.kind !== 'tag' || s.sourceAccts?.length)
34
39
  .filter(s => !localOnly || (s.kind === 'post' && s.visibility !== 'private'))
35
40
  .sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
36
41
  const { items: page, headers } = api.page(items, url);
@@ -145,8 +145,11 @@ export class AcctFeed {
145
145
  return map;
146
146
  }
147
147
 
148
+ // A server that does not say what is left (not every Mastodon-API server
149
+ // does) is not taken to have nothing left.
148
150
  _rateGuard(id, handle, res) {
149
- const left = Number(res.headers.get('x-ratelimit-remaining'));
151
+ const said = res.headers.get('x-ratelimit-remaining');
152
+ const left = said == null || said.trim() === '' ? NaN : Number(said);
150
153
  if (!Number.isFinite(left) || left > RATE_FLOOR) return false;
151
154
  const reset = Date.parse(res.headers.get('x-ratelimit-reset') || '');
152
155
  const wait = Number.isFinite(reset) ? Math.max(0, reset - Date.now()) : BACKOFF_MIN_MS;
@@ -81,8 +81,11 @@ export class TagFeed {
81
81
  if (Array.isArray(patch.tags)) clean.tags = patch.tags.map(t => String(t).replace(/^#/, '').trim()).filter(Boolean);
82
82
  if (patch.intervalMin) clean.intervalMin = Math.max(5, Number(patch.intervalMin) || DEFAULTS.intervalMin);
83
83
  this.store.write('tagfeed.json', { ...this.config(), ...clean });
84
+ // A feed that sweeps starts again on the new list; one that only answers
85
+ // for the list (fedipod.net, for an app) was never started and is not now.
86
+ const sweeping = this.stopped === false;
84
87
  this.stop();
85
- this.start();
88
+ if (sweeping) this.start();
86
89
  return this.config();
87
90
  }
88
91
 
@@ -227,6 +227,8 @@ function fromLiteral(term) {
227
227
  return term.value;
228
228
  }
229
229
 
230
+ const isTagged = (term) => term.termType === 'Literal' && !!term.language;
231
+
230
232
  function toValue(term, ctx, depth, path) {
231
233
  if (term.termType === 'Literal') return fromLiteral(term);
232
234
  if (isListHead(term, ctx)) return readList(term.value, ctx, depth, path);
@@ -248,8 +250,14 @@ function readProperty(subject, name, ctx, depth = 0, path = new Set()) {
248
250
 
249
251
  const term = TERMS.get(name);
250
252
  if (!term) return undefined;
251
- const values = preds.get(term.iri);
253
+ let values = preds.get(term.iri);
252
254
  if (!values || !values.length) return undefined;
255
+ // `content` and `contentMap` are one predicate, and Mastodon sends a post's
256
+ // text both ways. The plain term is the untagged copy, else the first tagged.
257
+ if (values.some(isTagged)) {
258
+ const plain = values.filter(v => !isTagged(v));
259
+ values = plain.length ? plain : [values.find(isTagged)];
260
+ }
253
261
 
254
262
  // `items` and `orderedItems` are the same predicate; the list is what tells
255
263
  // them apart, so asking for the ordered one only answers when it is ordered.
@@ -151,9 +151,17 @@ export class Intake {
151
151
  rec.last = String(message || '').slice(0, 200);
152
152
  all[url] = rec;
153
153
  this.store.write(ATTEMPTS_DOC, all);
154
+ if (rec.n === 2) this._trouble('failing', url, message);
154
155
  return rec.n;
155
156
  }
156
157
 
158
+ // Mail that could not be read, as against mail refused: a second failed try,
159
+ // or a piece set aside after its last. `onTrouble` tells the owner.
160
+ _trouble(kind, url, reason) {
161
+ try { this.onTrouble?.({ kind, url, reason: String(reason || '').slice(0, 200), at: new Date().toISOString() }); }
162
+ catch { /* telling must never stop the drain */ }
163
+ }
164
+
157
165
  _clearAttempt(url) {
158
166
  const all = this.store.read(ATTEMPTS_DOC, {});
159
167
  if (!all[url]) return;
@@ -202,13 +210,25 @@ export class Intake {
202
210
  this._drainAgain = false;
203
211
  // Paced, not immediate: chained sweeps put a ceiling on work per unit
204
212
  // time, so a delivery flood cannot run the drain back-to-back.
205
- const t = setTimeout(() => this.drain().catch(e => this.log(`drain: ${e.message}`)), CHAIN_GAP_MS);
206
- t.unref?.();
213
+ this._chainTimer = setTimeout(() => {
214
+ this._chainTimer = null;
215
+ this.drain().catch(e => this.log(`drain: ${e.message}`));
216
+ }, CHAIN_GAP_MS);
217
+ this._chainTimer.unref?.();
207
218
  }
208
219
  });
209
220
  return this._draining;
210
221
  }
211
222
 
223
+ // Resolves once no sweep is running and none is waiting to follow on, so a
224
+ // browser worker can stay up until the round it started is done.
225
+ async settled() {
226
+ while (this._draining || this._chainTimer) {
227
+ if (this._draining) await this._draining.catch(() => {});
228
+ else await new Promise((r) => setTimeout(r, 500));
229
+ }
230
+ }
231
+
212
232
  // Discard the content waiting in the inbox from before `before`, on the
213
233
  // owner's say-so — the admin page asks, this does it. NOT a blind sweep:
214
234
  // every item is read, because the type is the only thing that decides its
@@ -447,6 +467,7 @@ export class Intake {
447
467
  this.log(`inbox batch ${url} attempt ${n}/${MAX_ITEM_ATTEMPTS}: ${e.message}`);
448
468
  if (n >= MAX_ITEM_ATTEMPTS) {
449
469
  this.store.addDeadLetter({ inboxUrl: url, reason: `failed ${n}x: ${e.message}`, activity: null });
470
+ this._trouble('set-aside', url, e.message);
450
471
  pending.push(url);
451
472
  }
452
473
  }
@@ -489,6 +510,7 @@ export class Intake {
489
510
  this.log(`inbox item ${url} attempt ${n}/${MAX_ITEM_ATTEMPTS}: ${e.message}`);
490
511
  if (n >= MAX_ITEM_ATTEMPTS) {
491
512
  this.store.addDeadLetter({ inboxUrl: url, reason: `failed ${n}x: ${e.message}`, activity: trimActivity(at.activity) });
513
+ this._trouble('set-aside', url, e.message);
492
514
  // The dead letter IS the record of this item — deleting before it is
493
515
  // written down would lose the only evidence it ever arrived, so it
494
516
  // goes through the same commit-then-delete batch as everything else.
@@ -522,6 +544,14 @@ export class Intake {
522
544
  const read = raw ? await readLenient(raw) : null;
523
545
  if (read?.degraded) this.log(`inbox item ${url} grounded to read: ${read.degraded}`);
524
546
  const activity = at.activity = read?.view ?? read?.doc ?? null;
547
+ // The gateway's test letter (lib/gateway/check-letter.mjs): read this far
548
+ // like any mail, then answered, and shown nowhere. An answer that does
549
+ // not go leaves it to be tried again.
550
+ const from = typeof activity?.actor === 'string' ? activity.actor : activity?.actor?.id;
551
+ if (from && this.checkLetters?.actor && from === this.checkLetters.actor) {
552
+ await this.checkLetters.taken(String(activity.id || ''));
553
+ return;
554
+ }
525
555
  // What the shapes make of it. This decides NOTHING — the activity is
526
556
  // handled either way. It is written down so that which shapes fire on
527
557
  // real traffic is a question with an answer, rather than a guess made
@@ -577,6 +607,7 @@ export class Intake {
577
607
  const batch = await this._batchEntries(url);
578
608
  if (!batch) {
579
609
  this.store.addDeadLetter({ inboxUrl: url, reason: 'unreadable batch', activity: null });
610
+ this._trouble('set-aside', url, 'unreadable batch');
580
611
  return { entries: 1, complete: true };
581
612
  }
582
613
  let complete = true;
@@ -596,6 +627,7 @@ export class Intake {
596
627
  this.log(`inbox batch entry ${key} attempt ${n}/${MAX_ITEM_ATTEMPTS}: ${err.message}`);
597
628
  if (n >= MAX_ITEM_ATTEMPTS) {
598
629
  this.store.addDeadLetter({ inboxUrl: key, reason: `failed ${n}x: ${err.message}`, activity: trimActivity(at.activity) });
630
+ this._trouble('set-aside', key, err.message);
599
631
  } else {
600
632
  complete = false;
601
633
  }
@@ -635,6 +667,7 @@ export class Intake {
635
667
  this.log(`held delivery ${key} attempt ${n}/${MAX_ITEM_ATTEMPTS}: ${err.message}`);
636
668
  if (n >= MAX_ITEM_ATTEMPTS) {
637
669
  this.store.addDeadLetter({ inboxUrl: key, reason: `failed ${n}x: ${err.message}`, activity: trimActivity(at.activity) });
670
+ this._trouble('set-aside', key, err.message);
638
671
  done.push(e.name);
639
672
  }
640
673
  }
@@ -138,8 +138,10 @@ export function receiptVouchesFor(intake, receipt, actor) {
138
138
  // Delete is verified — the same verify-by-dereference the rest of intake uses.
139
139
  export async function isGone(intake, url) {
140
140
  let res;
141
+ // A deliverer that throws on a refusal (the browser's relay) still carries
142
+ // the origin's answer: 404 or 410 from the origin itself is gone.
141
143
  try { res = await intake.deliverer.signedFetch(url, { headers: { accept: ACCEPT_AP } }); }
142
- catch { return null; }
144
+ catch (e) { return e?.answered && (e.status === 404 || e.status === 410) ? true : null; }
143
145
  if (res.status === 404 || res.status === 410) return true;
144
146
  if (res.status < 400) {
145
147
  // A Tombstone answers 200 and still means deleted.
@@ -1,4 +1,6 @@
1
1
  // pod-only.mjs — what stays on the pod, and only there, when an account works
2
2
  // from a copy kept at its gateway (lib/gateway/copy.mjs): the signing key, the
3
- // pod's own lease, and the passwords and tokens of accounts on other servers.
4
- export const podOnly = (name) => name === 'keys.json' || name === 'lease.json' || name.startsWith('conn-');
3
+ // pod's own lease, and the passwords and tokens of accounts on other servers
4
+ // (`conn-*` as the vault names them, and the browser's two documents).
5
+ const CONNECTED = new Set(['fediaccts.json', 'atproto.json']);
6
+ export const podOnly = (name) => name === 'keys.json' || name === 'lease.json' || name.startsWith('conn-') || CONNECTED.has(name);
@@ -99,13 +99,26 @@ export class Publisher {
99
99
  // `clientOrigin` is overridden only by the agent serving a client its own
100
100
  // local actor: standalone, the published document cannot name a loopback
101
101
  // address, but a client already talking to that address can be told it.
102
+ // Whether follows wait for the owner, from the setting as saved now: this
103
+ // config is a copy taken when the agent started, and the saved one may have
104
+ // changed since.
105
+ approvesByHand() {
106
+ const saved = this.store?.getConfig?.() || {};
107
+ return wire.followsNeedApproval({ ...this.config,
108
+ ...('autoAcceptFollows' in saved ? { autoAcceptFollows: saved.autoAcceptFollows } : {}),
109
+ ...('approveJoins' in saved ? { approveJoins: saved.approveJoins } : {}) });
110
+ }
111
+
112
+ // Whether the profile last published says what the setting says.
113
+ profileAgrees() { return this.store.read('published.json', {}).approvesByHand === this.approvesByHand(); }
114
+
102
115
  actorDocFor({ priv = false, moderators = null, inbox = null, movedTo = this.config.movedTo || null,
103
116
  clientOrigin = this.clientOrigin } = {}) {
104
117
  const { urls } = this;
105
118
  return wire.actorDoc({
106
119
  urls, handle: wire.publicHandle(this.config), name: this.config.name, publicKeyPem: this.publicKeyPem,
107
120
  movedTo, kind: this.config.kind,
108
- approveJoins: wire.followsNeedApproval(this.config),
121
+ approveJoins: this.approvesByHand(),
109
122
  assertionKey: this.assertionKey,
110
123
  summary: this.config.summary || null, icon: this.config.icon || null,
111
124
  image: this.config.image || null, fields: this.config.fields || [],
@@ -283,10 +296,11 @@ export class Publisher {
283
296
  // one the operator makes because the first did not work — was skipped as a
284
297
  // no-op and reported success. The digest is a record of what is up there,
285
298
  // and an unreachable document is not up there.
286
- if (!unreachable.length) {
287
- this.store.write('published.json',
288
- { ...this.store.read('published.json', {}), surfaceDigest: surface });
289
- }
299
+ // What the actor now says about followers is recorded whatever else was
300
+ // unreachable: it was written, and a start that finds it disagreeing
301
+ // writes it again (profileAgrees).
302
+ this.store.write('published.json', { ...this.store.read('published.json', {}),
303
+ ...(!unreachable.length ? { surfaceDigest: surface } : {}), approvesByHand: !!actorDoc.manuallyApprovesFollowers });
290
304
  // Named as the fediverse sees it: a fronted actor's name and host are the
291
305
  // front's, not the pod's.
292
306
  const pubName = wire.publicHandle(this.config);
@@ -210,6 +210,7 @@ export async function rebuildStatuses(publisher, { fromNotes = false } = {}) {
210
210
  // merge-only rebuild when something is.
211
211
  export async function healStatuses(publisher) {
212
212
  const { urls, store } = publisher;
213
+ if (store.unread?.has('statuses.json')) return { missing: 0, recovered: 0 }; // nothing to compare with
213
214
  const own = store.read('outbox.json', [])
214
215
  .map(i => (typeof i === 'string' ? i : null))
215
216
  .filter(id => id && id.startsWith(urls.notes));
@@ -90,6 +90,10 @@ export class PodStore {
90
90
  this._held = 0;
91
91
  this.chain = Promise.resolve(); // serialized writes
92
92
  this.verdicts = new Map(); // name → did its last write land? (read by commit)
93
+ // Documents the pod holds that could not be read, and that nothing here
94
+ // has a copy of. Never written to until they are read: what this store
95
+ // would send is not the document, and would replace it on the pod.
96
+ this.unread = new Set();
93
97
  // Moves on each time queued writes are dropped (discardPending): work that
94
98
  // began in an earlier generation has lost results, whatever a commit says.
95
99
  this.generation = 0;
@@ -106,7 +110,7 @@ export class PodStore {
106
110
  attach(storage) {
107
111
  // A different tree is different state: carrying the old cache and its
108
112
  // ETags across would serve one container's documents as another's.
109
- if (this.storage && this.storage.base !== storage.base) { this.cache.clear(); this.etags.clear(); this.lastText.clear(); }
113
+ if (this.storage && this.storage.base !== storage.base) { this.cache.clear(); this.etags.clear(); this.lastText.clear(); this.unread.clear(); }
110
114
  this.storage = storage;
111
115
  }
112
116
 
@@ -137,8 +141,8 @@ export class PodStore {
137
141
  if (listing.missing && this.storage.kind === 'pod') {
138
142
  throw new Error(`state container missing (HTTP 404) at ${this.base} — pod unreachable or state gone`);
139
143
  }
140
- this.etags.set('', listing.etag);
141
144
  const names = listing.names.filter(n => n.endsWith('.json'));
145
+ for (const name of this.unread) if (!names.includes(name)) this.unread.delete(name); // gone: nothing to write over
142
146
  let fetched = 0;
143
147
  const skipped = [];
144
148
  for (const name of names) {
@@ -147,17 +151,20 @@ export class PodStore {
147
151
  if (r.notModified) continue; // ours is current
148
152
  if (!r.ok) {
149
153
  // One unreadable document must not restart the whole sweep — skip it,
150
- // keep whatever we already hold, and try again next load (no etag is
151
- // recorded, so the retry is unconditional). config.json is the
154
+ // keep whatever we already hold, and try again next load (neither its
155
+ // etag nor the container's is recorded, so the next load lists and
156
+ // reads it again). config.json is the
152
157
  // exception: without it the caller cannot tell "never set up" from
153
158
  // "could not read", and would tell the user to run setup.
154
159
  if (name === 'config.json' && !this.cache.has(name)) {
155
160
  throw new Error(`state doc ${name} unreadable (HTTP ${r.status})`);
156
161
  }
162
+ if (!this.lastText.has(name)) this.unread.add(name);
157
163
  skipped.push(`${name} (HTTP ${r.status})`);
158
164
  continue;
159
165
  }
160
166
  fetched++;
167
+ this.unread.delete(name);
161
168
  this.etags.set(name, r.etag);
162
169
  try { this.cache.set(name, JSON.parse(r.body)); this.lastText.set(name, r.body); }
163
170
  catch (e) { this.log(`state load ${name}: unparsable (${e.message})`); }
@@ -165,7 +172,12 @@ export class PodStore {
165
172
  // Kept for /status: a document skipped here is a timeline or a contact
166
173
  // list quietly missing, and the owner should be able to see that.
167
174
  this.lastSkipped = skipped;
168
- if (skipped.length) this.log(`state load skipped ${skipped.length}: ${skipped.join(', ')}`);
175
+ // The container's ETag vouches for every document in it, so it is kept
176
+ // only when every one was read; otherwise the next load would stop at
177
+ // "not modified" and never retry what was skipped.
178
+ if (skipped.length) this.etags.delete(''); else this.etags.set('', listing.etag);
179
+ if (skipped.length) this.log(`state load skipped ${skipped.length}: ${skipped.join(', ')}`
180
+ + (this.unread.size ? ` — not written until read: ${[...this.unread].join(', ')}` : ''));
169
181
  this.log(`state loaded: ${this.cache.size} doc(s) from ${this.base} (${fetched} re-fetched)`);
170
182
  this.onLoaded?.();
171
183
  }
@@ -177,6 +189,7 @@ export class PodStore {
177
189
  for (const { name, text, etag } of docs) {
178
190
  try { this.cache.set(name, JSON.parse(text)); } catch { continue; }
179
191
  this.lastText.set(name, text);
192
+ this.unread.delete(name);
180
193
  if (etag) this.etags.set(name, etag);
181
194
  }
182
195
  if (listingEtag) this.etags.set('', listingEtag);
@@ -193,11 +206,12 @@ export class PodStore {
193
206
  const r = await this.storage.read(name, etag ? { etag } : undefined);
194
207
  if (r.notModified) continue;
195
208
  if (r.status === 404) {
196
- this.cache.delete(name); this.lastText.delete(name); this.etags.delete(name);
209
+ this.cache.delete(name); this.lastText.delete(name); this.etags.delete(name); this.unread.delete(name);
197
210
  } else if (!r.ok) {
198
211
  throw new Error(`state doc ${name} unreadable (HTTP ${r.status})`);
199
212
  } else {
200
213
  this.cache.set(name, JSON.parse(r.body)); this.lastText.set(name, r.body); this.etags.set(name, r.etag);
214
+ this.unread.delete(name);
201
215
  }
202
216
  this.onSettled?.(name);
203
217
  }
@@ -216,6 +230,8 @@ export class PodStore {
216
230
  write(name, obj) {
217
231
  this.cache.set(name, structuredClone(obj));
218
232
  if (!this.storage) return;
233
+ // Not sent: the next commit says it did not land, and reads it first.
234
+ if (this.unread.has(name)) { this.verdicts.set(name, false); return; }
219
235
  // The same bytes the pod already holds are not sent again. A like, a
220
236
  // bookmark, a sweep that found nothing new each used to upload the whole
221
237
  // timeline index and the whole people cache, unchanged — the largest
@@ -263,6 +279,7 @@ export class PodStore {
263
279
  // the returned promise so one failure cannot poison the queue.
264
280
  _put(name) {
265
281
  const done = this.chain.then(async () => {
282
+ if (this.unread.has(name)) return false;
266
283
  const body = serialise(this.cache.get(name));
267
284
  await Promise.resolve(this.onWriting?.(name, body)).catch(() => {});
268
285
  for (let attempt = 1; attempt <= PUT_RETRIES; attempt++) {
@@ -313,6 +330,10 @@ export class PodStore {
313
330
  // `since`: the generation the caller's work began in. Anything dropped since
314
331
  // makes the answer false.
315
332
  async commit({ since } = {}) {
333
+ // A document that could not be read is read now, replacing whatever was
334
+ // written to it here; those writes already count as not landed, so the
335
+ // drain keeps its items and takes them again against the real document.
336
+ if (this.unread.size && this.storage) await this.refresh([...this.unread]).catch(() => {});
316
337
  const pending = [];
317
338
  // Held names too, or a sweep that suspended the debounce would delete the
318
339
  // pod's copy of an item whose result had not been written down — the one
@@ -18,6 +18,7 @@ import { Deliverer } from '../core/deliver.mjs';
18
18
  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
+ import { TagFeed } from '../connections/tagfeed.mjs';
21
22
  import { keeperSession } from './keeper-session.mjs';
22
23
  import { CopyStorage, copyMeta, copyLease, keptBefore, holdOn, GATEWAY_HOLDER } from './copy.mjs';
23
24
  import { makeCopy } from './state-api.mjs';
@@ -76,14 +77,15 @@ export async function settle(ctx, handle, rec, at, { log = console.log } = {}) {
76
77
 
77
78
  /**
78
79
  * The account's state store and lease, as `holder`: over the copy when there
79
- * is one, else over the pod.
80
+ * is one, else over the pod. `podNames`: what of the pod-only documents the
81
+ * store loads with the copy.
80
82
  */
81
- export function stateAndLease(ctx, handle, at, { log = console.log, holder = GATEWAY_HOLDER } = {}) {
83
+ export function stateAndLease(ctx, handle, at, { log = console.log, holder = GATEWAY_HOLDER, podNames = ['keys.json'] } = {}) {
82
84
  const podState = new HttpStorage(at.urls.state, at.podFetch);
83
85
  if (at.inCopy) {
84
86
  return {
85
- // Of what stays on the pod, only the key: one read per piece of work.
86
- store: new PodStore({ storage: new CopyStorage(ctx.copyKv, handle, { holder, pod: podState, podNames: ['keys.json'] }), log }),
87
+ // Of what stays on the pod, only the key, by default: one read per piece of work.
88
+ store: new PodStore({ storage: new CopyStorage(ctx.copyKv, handle, { holder, pod: podState, podNames }), log }),
87
89
  lease: copyLease(ctx.copyKv, handle, { id: holder, log }),
88
90
  };
89
91
  }
@@ -99,7 +101,7 @@ export function stateAndLease(ctx, handle, at, { log = console.log, holder = GAT
99
101
  * back onto the pod), the key from the pod, a deliverer that sends only when
100
102
  * asked, a publisher, and the intake. Returns the agent, or { skipped }.
101
103
  */
102
- export async function actingAgent(at, store, lease, { log = console.log, push = false } = {}) {
104
+ export async function actingAgent(at, store, lease, { log = console.log, push = false, checkLetters = null } = {}) {
103
105
  const config = store.getConfig();
104
106
  if (!config) return { skipped: 'no account on the pod' };
105
107
  const publicBase = config.gateway?.frontActor ? config.gateway.frontActor.replace(/ap\/actor\/?$/u, '') : null;
@@ -115,6 +117,8 @@ export async function actingAgent(at, store, lease, { log = console.log, push =
115
117
  // What the owner's own posts, taken at the outbox door, need of an agent.
116
118
  const agent = {
117
119
  store, publisher, deliverer, remote: at.remote, urls, config, lease, intake: null, viewer: false,
120
+ // The hashtags followed, changed from an app; FediPod in the browser reads them.
121
+ tagfeed: new TagFeed({ store, intake: null, log }),
118
122
  configured: () => true, requestTakeover: async () => true,
119
123
  };
120
124
  const c2s = new C2S({ agent, log });
@@ -122,6 +126,8 @@ export async function actingAgent(at, store, lease, { log = console.log, push =
122
126
  config, urls, remote: at.remote, store, deliverer, publisher, log, lease, push,
123
127
  ownerPost: (a, o) => c2s.dispatch(a, o),
124
128
  });
129
+ // The gateway's test letter, taken in here and answered (check-letter.mjs).
130
+ agent.intake.checkLetters = checkLetters;
125
131
  publisher.resolveActor = (u) => agent.intake.fetchAP(u);
126
132
  return agent;
127
133
  }