fedipod 1.50.2 → 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 (40) 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/timelines.mjs +5 -0
  5. package/lib/connections/acctfeed.mjs +4 -1
  6. package/lib/connections/tagfeed.mjs +4 -1
  7. package/lib/core/intake/index.mjs +35 -2
  8. package/lib/core/intake/verify.mjs +3 -1
  9. package/lib/core/pod-only.mjs +4 -2
  10. package/lib/core/publisher/index.mjs +19 -5
  11. package/lib/gateway/account-agent.mjs +11 -5
  12. package/lib/gateway/check-letter.mjs +123 -0
  13. package/lib/gateway/connected-feeds.mjs +116 -0
  14. package/lib/gateway/copy.mjs +19 -0
  15. package/lib/gateway/front-core.mjs +5 -3
  16. package/lib/gateway/held-mail.mjs +25 -1
  17. package/lib/gateway/keeper.mjs +2 -1
  18. package/lib/gateway/masto-gateway.mjs +47 -7
  19. package/lib/surface/routes/owner.mjs +7 -4
  20. package/package.json +3 -3
  21. package/scripts/stage-site.mjs +2 -3
  22. package/vendor/sanitize-html.cjs +358 -171
  23. package/web/admin/bar.js +45 -0
  24. package/web/admin/group.js +17 -0
  25. package/web/admin/index.html +12 -8
  26. package/web/admin/record.js +19 -41
  27. package/web/admin/upkeep.js +3 -0
  28. package/web/app/README.md +3 -3
  29. package/web/app/admin-facade.mjs +19 -16
  30. package/web/app/agent.mjs +65 -2
  31. package/web/app/atproto-browser.mjs +28 -22
  32. package/web/app/deliver-relay.mjs +2 -1
  33. package/web/app/dist/boot.js +1 -0
  34. package/web/app/dist/boot.js.map +2 -2
  35. package/web/app/dist/sw.js +567 -265
  36. package/web/app/dist/sw.js.map +4 -4
  37. package/web/app/fediacct-browser.mjs +32 -30
  38. package/web/app/idb-kv.mjs +4 -4
  39. package/web/app/shims/prelude.js +1 -0
  40. package/web/app/sw-src.mjs +44 -3
package/web/admin/bar.js CHANGED
@@ -4,6 +4,22 @@
4
4
  // relationship.
5
5
 
6
6
  (() => {
7
+ // A page loaded past its worker (a hard reload does that) leaves FediPod's
8
+ // own part asleep in the browser: nothing the page asks reaches it. Loaded
9
+ // again, normally, the worker answers. Once per tab, in case it cannot.
10
+ try {
11
+ const sw = navigator.serviceWorker;
12
+ const reloaded = performance.getEntriesByType?.('navigation')?.[0]?.type === 'reload';
13
+ if (sw?.controller) sessionStorage.removeItem('fedipod-reattached');
14
+ else if (sw && reloaded && !sessionStorage.getItem('fedipod-reattached')) {
15
+ sw.getRegistration().then((reg) => {
16
+ if (!reg?.active) return;
17
+ sessionStorage.setItem('fedipod-reattached', '1');
18
+ location.reload();
19
+ }).catch(() => {});
20
+ }
21
+ } catch { /* no storage to guard a second try: leave the page as it is */ }
22
+
7
23
  // The three destinations are <a href> now: they are navigations, so they
8
24
  // belong in a screen reader's links list, and middle-click and open-in-new-tab
9
25
  // work the way they do everywhere else. Nothing here has to drive them.
@@ -28,6 +44,7 @@
28
44
  // leaves the account looking broken from outside. Say so where the
29
45
  // account is, and let the owner take it back from here.
30
46
  if (s?.mode === 'viewer') viewerBanner();
47
+ if (s?.trouble?.count > 0) troubleBanner(s.trouble.count);
31
48
  }).catch(() => { /* not up yet; the bar still works */ });
32
49
 
33
50
  // The actors dropdown, on a page whose bar carries one. The record page
@@ -64,6 +81,34 @@
64
81
  }
65
82
  })();
66
83
 
84
+ // Mail FediPod could not read, until its owner opens the dead letters
85
+ // (upkeep.js), on every page with the bar.
86
+ function troubleBanner(count) {
87
+ if (document.getElementById('trouble-banner')) return;
88
+ const base = location.pathname.replace(/\/admin\/.*$/u, '');
89
+ const onManage = !!document.getElementById('do-deadletter');
90
+ const box = document.createElement('div');
91
+ box.id = 'trouble-banner';
92
+ box.setAttribute('role', 'alert');
93
+ box.style.cssText = 'padding:.7rem 1rem;background:var(--field-bg,#ececec);color:var(--fg,#1a1a1a);'
94
+ + 'border-bottom:3px solid var(--err,#b00020);border-left:6px solid var(--err,#b00020);font:inherit';
95
+ const head = document.createElement('strong');
96
+ head.style.color = 'var(--err,#b00020)';
97
+ head.textContent = 'Mail not read. ';
98
+ const said = document.createElement('span');
99
+ said.textContent = `FediPod could not read ${count === 1 ? 'a piece' : `${count} pieces`} of your mail. `;
100
+ box.append(head, said);
101
+ if (onManage) {
102
+ box.append('Show dead letters lists what it gave up on.');
103
+ } else {
104
+ const go = document.createElement('a');
105
+ go.href = base + '/admin/';
106
+ go.textContent = 'See them on your manage page';
107
+ box.append(go);
108
+ }
109
+ document.body.prepend(box);
110
+ }
111
+
67
112
  // "Another device is active": what it means for this account, and the way out.
68
113
  // The lease is a document on the pod with a five-minute life; a browser that
69
114
  // was closed leaves one behind that nothing renews, and every reload asks
@@ -110,6 +110,23 @@ const setAutoAccept = async (auto) => {
110
110
  onPick(FOLLOWS_PICK, (v) => setAutoAccept(v === 'auto'),
111
111
  () => { FOLLOWS_PICK.value = config?.autoAcceptFollows ? 'auto' : 'approve'; });
112
112
 
113
+ // Hashtag posts in the home timeline, or only on the public timeline and each
114
+ // hashtag's page. FediPod goes on bringing them in either way.
115
+ let homeTagsBusy = false;
116
+ const setTagsInHome = async (show) => {
117
+ if (homeTagsBusy || show === (config.tagsInHome !== false)) return;
118
+ homeTagsBusy = true;
119
+ try {
120
+ if (await write('/config', { tagsInHome: show },
121
+ show ? 'hashtag posts are shown in your home timeline' : 'hashtag posts are kept out of your home timeline')) {
122
+ config.tagsInHome = show;
123
+ }
124
+ HOME_TAGS_PICK.value = config.tagsInHome !== false ? 'show' : 'hide';
125
+ } finally { homeTagsBusy = false; }
126
+ };
127
+ onPick(HOME_TAGS_PICK, (v) => setTagsInHome(v === 'show'),
128
+ () => { HOME_TAGS_PICK.value = config?.tagsInHome !== false ? 'show' : 'hide'; });
129
+
113
130
  // One row: what it is, then what can be done to it.
114
131
  function row(text, sub, actions) {
115
132
  const li = document.createElement('li');
@@ -129,11 +129,10 @@ dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
129
129
  .muted { color: var(--muted); }
130
130
  /* The actors dropdown rides the bar, at the bar's own text scale. */
131
131
  #bar select { font-size: .95rem; }
132
- /* The per-account Options disclosure: only where storage is not a real choice
133
- (the Node agent's Bluesky, cross-post instead) — a summary opening
134
- Show/Hide in Feed, Cross-post On/Off, Connected/Disconnect. Where storage
135
- IS a choice, two dropdowns sit in the row directly instead (admin.js
136
- connectionControls). */
132
+ /* The per-account Options disclosure, for the Node agent's Bluesky — a
133
+ summary opening Show/Hide in Feed, Cross-post On/Off, Connected/Disconnect.
134
+ A browser-build account's dropdown sits in the row directly instead
135
+ (record.js connectionControls). */
137
136
  details.opts { display: inline-block; }
138
137
  details.opts > summary { cursor: pointer; color: var(--link); list-style: none; }
139
138
  details.opts > summary::-webkit-details-marker { display: none; }
@@ -143,9 +142,8 @@ details.opts > div { margin: .4rem 0 .2rem; padding: .5rem .6rem; display: flex;
143
142
  gap: .4rem; align-items: stretch; border: 1px solid var(--line); border-radius: .3rem;
144
143
  background: var(--raised); min-width: 11rem; }
145
144
  details.opts > div select { width: 100%; }
146
- .opt-hint { color: var(--muted); font-size: .9rem; margin: -.1rem 0 .1rem; }
147
- /* The connection and storage dropdowns that sit in the row itself (admin.js
148
- connectionControls) — extra room from the account name before them. */
145
+ /* The connection dropdown that sits in the row itself (record.js
146
+ connectionControls) — extra room from the account name before it. */
149
147
  .conn-controls { display: inline-flex; align-items: center; gap: .4rem; margin-left: 1rem; }
150
148
  /* The alias row inside the Transfer-an-account-here panel. */
151
149
  #alias-ctl { display: flex; gap: .5rem; align-items: center; margin-top: .3rem; }
@@ -327,6 +325,12 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
327
325
  <option value="auto">accepted automatically</option>
328
326
  </select>
329
327
  </span>
328
+ <span id="home-tags-ctl" hidden>
329
+ <select id="home-tags-pick" aria-label="Hashtag posts in your home timeline" title=" Whether posts brought in for the hashtags you follow appear in your home timeline; hidden, they stay on the public timeline and on each hashtag's page">
330
+ <option value="show">shown</option>
331
+ <option value="hide">hidden</option>
332
+ </select>
333
+ </span>
330
334
  <span id="ident-ctl" hidden>
331
335
  <button type="button" id="ident-open" class="inline" title=" Connect an account you hold on another network, so its timeline joins the one you read here">Connect an identity</button>
332
336
  </span>
@@ -17,6 +17,8 @@ const STATUS_PICK = $('status-pick'); // held: getElementById can't see it mid
17
17
  const IDENT_CTL = $('ident-ctl');
18
18
  const FOLLOWS_CTL = $('follows-ctl');
19
19
  const FOLLOWS_PICK = $('follows-pick');
20
+ const HOME_TAGS_CTL = $('home-tags-ctl');
21
+ const HOME_TAGS_PICK = $('home-tags-pick');
20
22
  const UPDATE_CTL = $('update-ctl');
21
23
  const UPDATE_WORD = $('update-word');
22
24
  const UPDATE_GO = $('update-go');
@@ -130,7 +132,6 @@ function identityRows() {
130
132
  crossPost: 'crossPost' in config.atproto ? config.atproto.crossPost : null,
131
133
  feed: (paused) => ['/atproto', { feedPaused: paused }],
132
134
  cross: (on) => ['/atproto', { crossPost: on }],
133
- store: (w) => ['/atproto', { storage: w }],
134
135
  off: () => ['/atproto/disconnect', {}],
135
136
  });
136
137
  for (const acct of config.fediAccounts || []) accounts.push({
@@ -141,7 +142,6 @@ function identityRows() {
141
142
  feedPaused: !acct.enabled,
142
143
  crossPost: null,
143
144
  feed: (paused) => ['/fediacct', { id: acct.id, enabled: !paused }],
144
- store: (w) => ['/fediacct', { id: acct.id, storage: w }],
145
145
  off: () => ['/fediacct/disconnect', { id: acct.id }],
146
146
  });
147
147
  for (const acct of accounts) {
@@ -149,18 +149,16 @@ function identityRows() {
149
149
  name.textContent = acct.handle;
150
150
  if (acct.href) { name.href = acct.href; name.target = '_blank'; name.rel = 'noopener'; name.title = ' Open this account elsewhere in a new tab'; }
151
151
  if (acct.warn) name.className = 'warn';
152
- // Where storage is a real choice (every browser-build account today), two
153
- // dropdowns sit in the row itself. Where it isn't (the Node agent's
154
- // Bluesky, which shows a cross-post toggle instead), the Options
155
- // disclosure it always has is unchanged.
152
+ // A browser-build account (it says where it is stored) gets its dropdown
153
+ // in the row itself. The Node agent's Bluesky, which shows a cross-post
154
+ // toggle instead, keeps the Options disclosure it always has.
156
155
  row(acct.label, name, ' ', ...(acct.storage ? connectionControls(acct) : [optionsMenu(acct)]));
157
156
  }
158
157
  return out;
159
158
  }
160
159
 
161
- // Connected, Paused or Disconnected, and where the key lives — one dropdown
162
- // each, in the row. Replaces the separate feed and connect dropdowns that used
163
- // to sit behind an Options disclosure; only reached where acct.storage is set.
160
+ // Connected, Paused or Disconnected, as one dropdown in the row; only reached
161
+ // where acct.storage is set. The key is kept on the pod, with no choice to make.
164
162
  function connectionControls(acct) {
165
163
  const send = async ([path, payload], msg) => { if (await write(path, payload, msg)) await load(); };
166
164
  const pick = (opts, current, onChange, title) => {
@@ -177,23 +175,14 @@ function connectionControls(acct) {
177
175
  ? send(acct.off(), 'disconnected')
178
176
  : send(acct.feed(v === 'paused'), v === 'paused' ? 'paused adding to your feed' : 'added to your home feed')),
179
177
  ' Connected feeds this account into your home feed here; Paused keeps the account but stops adding its posts; Disconnected removes it.');
180
- const storage = pick(
181
- [['pod', 'Store key on Pod'], ['browser', 'Store key in Browser']],
182
- acct.storage,
183
- (v) => {
184
- if (v === 'pod' && !confirm('Store this account on your pod?\n\nIt will follow you to any browser you sign in from — but a full-access token to that account then lives on your pod.')) { load(); return; }
185
- send(acct.store(v), v === 'pod' ? 'stored on your pod' : 'stored in this browser');
186
- },
187
- ' On your pod the key follows you to any browser you sign in from, and a full-access token then lives there; in this browser the token never leaves this device.');
188
178
  const wrap = document.createElement('span');
189
179
  wrap.className = 'conn-controls';
190
- wrap.append(conn, ' ', storage);
180
+ wrap.append(conn);
191
181
  return [wrap];
192
182
  }
193
183
 
194
- // Every connected account without a storage choice carries an Options menu
195
- // instead: pause its feed, its cross-post toggle (the Node agent's Bluesky),
196
- // disconnect.
184
+ // The Node agent's Bluesky carries an Options menu instead: pause its feed,
185
+ // its cross-post toggle, disconnect.
197
186
  function optionsMenu(acct) {
198
187
  const d = document.createElement('details');
199
188
  d.className = 'opts';
@@ -218,14 +207,8 @@ function optionsMenu(acct) {
218
207
  pick([['show', 'Add to home feed'], ['hide', 'Pause adding to home feed']], acct.feedPaused ? 'hide' : 'show',
219
208
  (v) => send(acct.feed(v === 'hide'), v === 'hide' ? 'paused adding to your feed' : 'added to your home feed'));
220
209
  }
221
- // Storage where the browser offers the choice; the Node agent's Bluesky shows
222
- // its cross-post toggle in the same slot instead.
223
- if (acct.storage) {
224
- pick([['browser', 'Store key in Browser'], ['pod', 'Store key on Pod']], acct.storage, (v) => {
225
- if (v === 'pod' && !confirm('Store this account on your pod?\n\nIt will follow you to any browser you sign in from — but a full-access token to that account then lives on your pod.')) { load(); return; }
226
- send(acct.store(v), v === 'pod' ? 'stored on your pod' : 'stored in this browser');
227
- });
228
- } else if (acct.crossPost !== null) {
210
+ // The Node agent's Bluesky: its cross-post toggle.
211
+ if (acct.crossPost !== null) {
229
212
  pick([['on', 'Cross-post On'], ['off', 'Cross-post Off']], acct.crossPost ? 'on' : 'off',
230
213
  (v) => send(acct.cross(v === 'on'), ''));
231
214
  }
@@ -233,14 +216,6 @@ function optionsMenu(acct) {
233
216
  pick([['connected', 'Connected'], ['disconnect', 'Disconnect']], 'connected',
234
217
  (v) => { if (v === 'disconnect') send(acct.off(), 'disconnected'); else load(); });
235
218
 
236
- // The storage consequence, at the bottom of the box under every dropdown.
237
- if (acct.storage) {
238
- const hint = document.createElement('p'); hint.className = 'opt-hint';
239
- hint.textContent = acct.storage === 'pod'
240
- ? 'Follows you to every browser you sign in from; a full-access token lives on your pod.'
241
- : 'Stays in this browser; the token never leaves this device.';
242
- body.appendChild(hint);
243
- }
244
219
  return d;
245
220
  }
246
221
 
@@ -265,9 +240,6 @@ function render() {
265
240
  ['Fediverse identity', config.address || `@${config.handle} — no resolvable address`,
266
241
  config.accountId && config.address ? { href: `/admin/client/#/a/${config.accountId}` } : null],
267
242
  ['Solid identity', config.webId, config.remotePod ? { href: config.remotePod, blank: true } : null],
268
- // Where on the pod the account's data lives: the container its owner chose.
269
- ['stored at', config.remotePod && config.root ? config.remotePod + config.root : null,
270
- config.remotePod && config.root ? { href: config.remotePod + config.root, blank: true } : null],
271
243
  ['Other identities', 'ctl'],
272
244
  ['local store', config.home],
273
245
  // The address you actually open, not the bare number — the named origin when
@@ -282,7 +254,7 @@ function render() {
282
254
  if (config.movedTo) rows.push(['moved to', config.movedTo]);
283
255
  // A person gates followers here; a group's gate is the joins control on its
284
256
  // kind row, so the row would be a second switch for the same thing.
285
- if (config.kind !== 'group') rows.splice(2, 0, ['new followers', 'ctl']);
257
+ if (config.kind !== 'group') rows.splice(2, 0, ['new followers', 'ctl'], ['hashtag posts in home', 'ctl']);
286
258
  // Rows that belong under the one being written, appended after it.
287
259
  let kids = null;
288
260
  for (const [k, v, link] of rows) {
@@ -323,6 +295,12 @@ function render() {
323
295
  FOLLOWS_CTL.hidden = false;
324
296
  FOLLOWS_PICK.value = config.autoAcceptFollows !== false ? 'auto' : 'approve';
325
297
  }
298
+ if (k === 'hashtag posts in home') {
299
+ dd.textContent = '';
300
+ dd.append(HOME_TAGS_CTL);
301
+ HOME_TAGS_CTL.hidden = false;
302
+ HOME_TAGS_PICK.value = config.tagsInHome !== false ? 'show' : 'hide';
303
+ }
326
304
  if (k === 'gateway') {
327
305
  dd.textContent = '';
328
306
  dd.append(GATEWAY_CTL);
@@ -64,6 +64,9 @@ $('do-deadletter').addEventListener('click', async () => {
64
64
  if (showingFrom('deadletter')) { closePanels(); return; }
65
65
  const { json } = await api('/deadletter');
66
66
  output(json?.items?.length ? json.items : 'no dead letters', 'deadletter');
67
+ // Looked at: the bar stops saying mail was not read.
68
+ const seen = await postJson('/trouble', { seen: true }).catch(() => null);
69
+ if (seen?.status === 200) document.getElementById('trouble-banner')?.remove();
67
70
  });
68
71
 
69
72
  // ---- lifecycle ----
package/web/app/README.md CHANGED
@@ -41,9 +41,9 @@ worker answers the data endpoints it calls. See
41
41
  | file | what |
42
42
  |---|---|
43
43
  | `admin-facade.mjs` | Answers the owner/manage endpoints (`/status`, `/config`, `/gateway`, `/alias`, `/rotate-key`, `/rebuild`, `/move`, `/retire`, `/inbox/prune`, `/deadletter`, `/blocks`, `/atproto*`, `/fediacct*`) over the browser agent, mirroring `lib/device/admin.mjs`. Personal only. |
44
- | `atproto-browser.mjs` | The Bluesky connection (`lib/connections/atproto.mjs`) with a per-connection storage choice — IndexedDB (this browser) or owner-only pod state — plus pause. |
45
- | `fediacct-browser.mjs` | Connections to fediverse accounts on other servers (`lib/connections/fediacct.mjs`): the OAuth dance, the same storage choice, and a best-effort server-side revoke on disconnect. |
46
- | `idb-kv.mjs` | The per-origin IndexedDB key/value store the two connectors use for "on this device, never the pod" credentials. |
44
+ | `atproto-browser.mjs` | The Bluesky connection (`lib/connections/atproto.mjs`), its credential kept in the owner-only pod state and nowhere else, plus pause. A record found in IndexedDB moves to the pod. |
45
+ | `fediacct-browser.mjs` | Connections to fediverse accounts on other servers (`lib/connections/fediacct.mjs`): the OAuth dance, the credential kept on the pod the same way, and a best-effort server-side revoke on disconnect. |
46
+ | `idb-kv.mjs` | The per-origin IndexedDB key/value store the two connectors use for app registrations, the OAuth pending state, and any credential found there, until it moves to the pod. |
47
47
 
48
48
  `agent.mjs` also holds the single-active-agent **lease** (`lib/core/lease.mjs`) — a
49
49
  second device runs read-only until it takes over — and starts the drain/mirrors
@@ -22,14 +22,14 @@ import { followActor, followHandle } from '../../lib/core/social.mjs';
22
22
  // The identity itself — changing any means a different actor, i.e. a new setup.
23
23
  const PERMANENT_CONFIG = ['handle', 'remotePod', 'issuer', 'root', 'kind'];
24
24
  // Carried in the actor document, so a change is not live until republished.
25
- const WIRE_CONFIG = ['name', 'summary', 'icon', 'image', 'fields', 'aliases'];
25
+ const WIRE_CONFIG = ['name', 'summary', 'icon', 'image', 'fields', 'aliases', 'autoAcceptFollows'];
26
26
 
27
27
  // The data endpoints this agent answers at the origin root (the page computes
28
28
  // its base as the origin, so it asks for /status, not /admin/status). Anything
29
29
  // not here falls through to the network as a static file or the gateway.
30
30
  export const ADMIN_PATHS = new Set([
31
31
  '/status', '/config', '/gateway', '/alias', '/rotate-key', '/import',
32
- '/deadletter', '/blocks', '/profiles', '/modqueue', '/log', '/fediacct', '/describe', '/follow',
32
+ '/deadletter', '/trouble', '/blocks', '/profiles', '/modqueue', '/log', '/fediacct', '/describe', '/follow',
33
33
  '/atproto', '/atproto/connect', '/atproto/disconnect',
34
34
  '/rebuild', '/move', '/retire', '/inbox/prune', '/park', '/revive', '/takeover',
35
35
  '/gateway/pause', '/gateway/keep', '/gateway/close',
@@ -120,6 +120,7 @@ export class AdminFacade {
120
120
  image: cfg.image || null, fields: cfg.fields || [],
121
121
  aliases: cfg.aliases || [],
122
122
  autoAcceptFollows: cfg.autoAcceptFollows !== false,
123
+ tagsInHome: cfg.tagsInHome !== false,
123
124
  quiescedAt: cfg.quiescedAt || null, movedTo: cfg.movedTo || null,
124
125
  mode: a.status().mode,
125
126
  // Live from the credential's actual backend on THIS browser (a
@@ -162,7 +163,7 @@ export class AdminFacade {
162
163
  });
163
164
  }
164
165
  case '/modqueue': return json(200, []); // person: no moderation queue
165
- case '/log': return json(200, { lines: [] }); // no local log file in the browser
166
+ case '/log': return json(200, { lines: (await a.recentLog?.()) || [] });
166
167
  case '/fediacct': return json(200, { accounts: a.fediaccts?.status() || [] });
167
168
  case '/import':
168
169
  return json(200, a.importer ? a.importer.progress() : { running: false, total: 0, done: 0 });
@@ -194,6 +195,13 @@ export class AdminFacade {
194
195
 
195
196
  // ---- writes ----
196
197
  switch (p) {
198
+ // The owner has opened the dead letters: the bar stops saying so.
199
+ case '/trouble': {
200
+ if (body.seen !== true) return json(400, { error: 'seen: true is the only thing to say here' });
201
+ a.troubleSeen?.();
202
+ await a.store.flush();
203
+ return json(200, { ok: true, trouble: a.status().trouble });
204
+ }
197
205
  case '/config': {
198
206
  const fixed = PERMANENT_CONFIG.filter(k => k in body);
199
207
  if (fixed.length) return json(400, { error: `${fixed.join(', ')} cannot be changed — that is the identity itself` });
@@ -216,6 +224,7 @@ export class AdminFacade {
216
224
  .map(f => ({ name: String(f.name).trim(), value: String(f.value ?? '').trim() }));
217
225
  }
218
226
  if ('autoAcceptFollows' in body) cfg.autoAcceptFollows = !!body.autoAcceptFollows;
227
+ if ('tagsInHome' in body) cfg.tagsInHome = !!body.tagsInHome;
219
228
  const republish = WIRE_CONFIG.some(k => k in body);
220
229
  a.store.setConfig(cfg);
221
230
  if ('autoAcceptFollows' in body && a.publisher) a.publisher.config.autoAcceptFollows = cfg.autoAcceptFollows;
@@ -511,21 +520,18 @@ export class AdminFacade {
511
520
  await a.store.flush();
512
521
  return json(200, { ok: true });
513
522
  }
514
- // Bluesky settings: pause/resume the mirror, and move the credential
515
- // between this browser and the pod. Cross-post is default-on, not toggled.
523
+ // Bluesky settings: pause/resume the mirror. Cross-post is default-on, not
524
+ // toggled. The credential is kept on the pod, with nowhere else to move.
516
525
  case '/atproto': {
517
526
  if (!a.atproto?.connected()) return json(400, { error: 'no bluesky account connected' });
527
+ if ('storage' in body) return json(400, { error: 'a connected account is kept on your pod' });
518
528
  if ('feedPaused' in body) { a.atproto.setFeedPaused(!!body.feedPaused); a.restartBsky?.(); }
519
- if ('storage' in body) {
520
- if (!['pod', 'browser'].includes(body.storage)) return json(400, { error: 'storage must be pod or browser' });
521
- a.atproto.setStorage(body.storage);
522
- }
523
529
  return json(200, { ok: true, ...a.atproto.status() });
524
530
  }
525
531
 
526
532
  // Connect a fediverse account on another server: start its OAuth at that
527
533
  // server. The browser is then sent to `authorize`; the code comes back to
528
- // GET /fediacct/callback (above). The token never touches the pod.
534
+ // GET /fediacct/callback (above). The token is kept on the pod.
529
535
  case '/fediacct/connect': {
530
536
  if (!body.host) return json(400, { error: 'the address of the server is required' });
531
537
  try {
@@ -540,14 +546,11 @@ export class AdminFacade {
540
546
  a.restartAccts?.();
541
547
  return json(200, { ok: true });
542
548
  }
543
- // Pause/resume the mirror, or move the credential between browser and pod.
549
+ // Pause/resume the mirror. The credential is kept on the pod.
544
550
  case '/fediacct': {
545
551
  if (!body.id) return json(400, { error: 'id required' });
546
- let row;
547
- if ('storage' in body) {
548
- if (!['pod', 'browser'].includes(body.storage)) return json(400, { error: 'storage must be pod or browser' });
549
- row = a.fediaccts.setStorage(body.id, body.storage);
550
- } else row = a.fediaccts.setEnabled(body.id, !!body.enabled);
552
+ if ('storage' in body) return json(400, { error: 'a connected account is kept on your pod' });
553
+ const row = a.fediaccts.setEnabled(body.id, !!body.enabled);
551
554
  if (!row) return json(404, { error: 'no such account' });
552
555
  a.restartAccts?.();
553
556
  return json(200, { ok: true, account: row });
package/web/app/agent.mjs CHANGED
@@ -183,6 +183,34 @@ export class BrowserAgent {
183
183
  // (lib/gateway/held-mail.mjs: /api/push). Named a moment later, once written,
184
184
  // so a burst is one call. One from another network is not pushed.
185
185
  static PUSH_AFTER_MS = 2_000;
186
+
187
+ // Mail FediPod could not read: counted until its owner opens the dead
188
+ // letters, shown in the bar meanwhile, and told to their phones at most
189
+ // once an hour (lib/gateway/held-mail.mjs: /api/alert).
190
+ static TROUBLE_ALERT_MS = 60 * 60_000;
191
+ noteTrouble(t, { tell = true } = {}) {
192
+ const had = this.store.read('trouble.json', null) || { count: 0 };
193
+ const now = Date.now();
194
+ const due = tell && now - (had.alertedAt ? Date.parse(had.alertedAt) : 0) >= BrowserAgent.TROUBLE_ALERT_MS;
195
+ const next = { ...had, count: (had.count || 0) + 1, firstAt: had.firstAt || t.at, lastAt: t.at,
196
+ last: { kind: t.kind, reason: t.reason }, ...(due ? { alertedAt: new Date(now).toISOString() } : {}) };
197
+ this.store.write('trouble.json', next);
198
+ this.log(`could not read mail (${t.kind}): ${t.reason}`);
199
+ if (due) {
200
+ const many = next.count === 1 ? 'a piece of your mail' : `${next.count} pieces of your mail`;
201
+ this.tellGateway('alert', { handle: this.doorKey, text: `Could not read ${many}. Show dead letters on your manage page lists what it gave up on.` }, { standing: false })
202
+ .catch(() => {});
203
+ }
204
+ }
205
+ // The owner has looked: the count starts again, and the hourly limit stands.
206
+ troubleSeen() {
207
+ const had = this.store.read('trouble.json', null);
208
+ if (had?.count) {
209
+ this.store.write('trouble.json', { count: 0, ...(had.alertedAt ? { alertedAt: had.alertedAt } : {}),
210
+ ...(had.letterMissing ? { letterMissing: had.letterMissing } : {}) });
211
+ }
212
+ }
213
+
186
214
  notePush(n) {
187
215
  if (!this.gatewayApi || n?.bsky || n?.via) return;
188
216
  if (!Object.keys(this.store.read('webpush.json', { subs: {} }).subs || {}).length) return;
@@ -243,6 +271,12 @@ export class BrowserAgent {
243
271
  async hereAtGateway() {
244
272
  const said = await this.tellGateway('here', { handle: this.doorKey, nextAt: this.store ? nextDue(this.store) : null });
245
273
  if (said?.status === 200) saveMeta(this.webId, { hereAt: Date.now() }).catch(() => {});
274
+ // The gateway's test letter has not arrived: the bar says so, once per
275
+ // letter. The gateway has told the phones itself.
276
+ if (said?.status === 200 && said.missingLetter && this.store?.read('trouble.json', null)?.letterMissing !== said.missingLetter) {
277
+ this.noteTrouble({ kind: 'test-letter', reason: "the gateway's test letter has not arrived", at: new Date().toISOString() }, { tell: false });
278
+ this.store.write('trouble.json', { ...this.store.read('trouble.json', {}), letterMissing: said.missingLetter });
279
+ }
246
280
  // The gateway's admin may have turned the hold off or on (copy-mode.mjs).
247
281
  if (said?.status === 200 && typeof said.hold === 'boolean') {
248
282
  await followHold(this, this.frontOrigin, said.hold).catch((e) => this.log(`the gateway's hold: ${e.message}`));
@@ -329,6 +363,14 @@ export class BrowserAgent {
329
363
  }
330
364
  }
331
365
  this.store.onEvent = (type, n) => { if (type === 'notification') this.notePush(n); };
366
+ // A profile that says otherwise than the follower setting (written by an
367
+ // older FediPod, or before the setting changed) is written again.
368
+ // Tried at most hourly, so a pod that refuses it is not asked at every wake.
369
+ const tried = this.store.read('published.json', {}).healTriedAt;
370
+ if (!this.publisher.profileAgrees() && !(tried && Date.now() - Date.parse(tried) < 60 * 60_000)) {
371
+ this.store.write('published.json', { ...this.store.read('published.json', {}), healTriedAt: new Date().toISOString() });
372
+ await this.publisher.publishProfile({ force: true }).catch((e) => this.log(`profile: ${e.message}`));
373
+ }
332
374
  const now = Date.now();
333
375
  // Said before the drain starts, so held mail is in the inbox to be read.
334
376
  const hereDue = !warm || now - (warm.hereAt || 0) >= BrowserAgent.HERE_EVERY_MS;
@@ -343,6 +385,9 @@ export class BrowserAgent {
343
385
  this._schedTimer = setInterval(() => { publishDue(this.store, this.publisher, this.log).catch(() => {}); }, 30_000);
344
386
  const drainNow = !warm || now - (warm.drainedAt || 0) >= DRAIN_EVERY_MS || here?.flushed > 0;
345
387
  await this.intake.start({ drainNow, subscribe: !warm || !!warm.resubscribe });
388
+ // Connected accounts still kept in this browser go to the pod.
389
+ await this.atproto.moveToPod?.().catch((e) => this.log(`Bluesky account to the pod: ${e.message}`));
390
+ await this.fediaccts.moveToPod?.().catch((e) => this.log(`connected accounts to the pod: ${e.message}`));
346
391
  this.startBsky();
347
392
  this.startAccts();
348
393
  this.tagfeed?.start(this.mirrorStart('tags'));
@@ -634,6 +679,17 @@ export class BrowserAgent {
634
679
  store: this.store, deliverer: this.deliverer, publisher: this.publisher, log: this.log, push: true, lease: this.lease,
635
680
  ownerPost: (a, o) => this.c2s.dispatch(a, o),
636
681
  });
682
+ this.intake.onTrouble = (t) => this.noteTrouble(t);
683
+ // The gateway's test letter: answered when taken in, and shown nowhere.
684
+ if (this.gatewayApi) {
685
+ this.intake.checkLetters = {
686
+ actor: `${this.frontOrigin.replace(/\/$/u, '')}/check/actor`,
687
+ taken: async (letter) => {
688
+ const said = await this.tellGateway('check', { handle: this.doorKey, letter }, { standing: false });
689
+ if (said?.status !== 200) throw new Error(`the gateway did not take the answer to its test letter (${said?.status || 'no answer'})`);
690
+ },
691
+ };
692
+ }
637
693
  // The Mastodon facade the service worker serves.
638
694
  //
639
695
  // It used to be handed `allowed: null`, which reads as "no policy" — and
@@ -685,8 +741,14 @@ export class BrowserAgent {
685
741
  // its last full start.
686
742
  if (!this._warm) await containers.provisionPrivate(this.remote, this.urls);
687
743
  // Connected-account records are reads — safe whether we act or view.
688
- await this.atproto.load(); // the Bluesky credential, if it is browser-stored
689
- await this.fediaccts.load(); // connected fediverse accounts, from both backends
744
+ // Connected accounts: their records stay on the pod only, so no listing
745
+ // of the gateway's copy names them; read at every full start, and at the
746
+ // first quick one that has not had them. A Bluesky credential or
747
+ // fediverse accounts still kept in this browser are read as well.
748
+ const readConn = !this._warm || !this._warm.connRead;
749
+ await this.atproto.load({ fromPod: readConn });
750
+ await this.fediaccts.load({ fromPod: readConn });
751
+ if (readConn) saveMeta(this.webId, { connRead: true }).catch(() => {});
690
752
  // The lease decides: this device ACTS on the pod, or reads it read-only.
691
753
  // A warm restart already holds it, and writes it only when renewal is due.
692
754
  this.viewer = !(this._warm && !this._warm.renewDue ? true : await this.lease.acquire());
@@ -734,6 +796,7 @@ export class BrowserAgent {
734
796
  update: null,
735
797
  inboxCooldownFor: 0,
736
798
  stateSkipped: this.store?.lastSkipped || [],
799
+ trouble: this.store?.read('trouble.json', null) || null,
737
800
  };
738
801
  }
739
802
 
@@ -2,16 +2,15 @@
2
2
  //
3
3
  // Same Atproto client the Node agent uses (lib/atproto.mjs). Two browser edges:
4
4
  //
5
- // 1. Storage is the owner's choice, per connection. "browser" keeps the
6
- // credential in IndexedDB on this device (default — a full-access token
7
- // stays here); "pod" keeps it in the owner-only pod state so it follows the
8
- // owner to any browser they sign in from. The record carries which, and
9
- // read/write route to that backend; moving between them is just a re-write.
5
+ // 1. The credential lives in the owner-only pod state, on the pod only
6
+ // (lib/core/pod-only.mjs), so it follows the owner to any browser they sign
7
+ // in from. A record an earlier build kept in this browser's IndexedDB is
8
+ // still read, and moveToPod() takes it to the pod.
10
9
  // 2. The mirror can be paused (feedPaused) without disconnecting.
11
10
  //
12
11
  // Bluesky's API answers CORS, so the fetches go direct, not through the relay.
13
12
  import { Atproto } from '../../lib/connections/atproto.mjs';
14
- import { kvGet, kvPut, kvDel } from './idb-kv.mjs';
13
+ import { kvGet, kvDel } from './idb-kv.mjs';
15
14
 
16
15
  const IDB_KEY = 'atproto';
17
16
  const POD_DOC = 'atproto.json';
@@ -20,19 +19,24 @@ export class BrowserAtproto extends Atproto {
20
19
  constructor({ store, actorId = null, log = console.log } = {}) {
21
20
  super({ localDir: '/atproto', actorId, log }); // localDir unused; storage below
22
21
  this.store = store;
23
- this._idbRec = null; // the browser-stored record, cached at boot
22
+ this._idbRec = null; // a record still in this browser, cached at boot
24
23
  }
25
24
 
26
25
  // The IndexedDB copy into memory, so read() stays synchronous as the base
27
26
  // class expects. The pod copy is already in the loaded store.
28
- async load() { this._idbRec = await kvGet(IDB_KEY).catch(() => null); return this; }
27
+ // `fromPod`: the pod's record read as well (agent.mjs says when).
28
+ async load({ fromPod = false } = {}) {
29
+ this._idbRec = await kvGet(IDB_KEY).catch(() => null);
30
+ if (fromPod) await this.store.refresh?.([POD_DOC], { ifChanged: true }).catch((e) => this.log(`Bluesky account: ${e.message}`));
31
+ return this;
32
+ }
29
33
 
30
34
  // Whichever backend holds it, tagged with where it lives. The stamped-record
31
35
  // guard is the base class's.
32
36
  read() {
33
37
  let rec = null; let where = null;
34
- if (this._idbRec) { rec = this._idbRec; where = 'browser'; }
35
- else { const p = this.store.read(POD_DOC, null); if (p) { rec = p; where = 'pod'; } }
38
+ const p = this.store.read(POD_DOC, null);
39
+ if (p) { rec = p; where = 'pod'; } else if (this._idbRec) { rec = this._idbRec; where = 'browser'; }
36
40
  if (!rec) return null;
37
41
  if (rec.mintedFor && this.actorId && rec.mintedFor !== this.actorId) {
38
42
  this.log(`atproto belongs to ${rec.mintedFor} — not reusing it for ${this.actorId}`);
@@ -41,18 +45,22 @@ export class BrowserAtproto extends Atproto {
41
45
  return { ...rec, storage: where };
42
46
  }
43
47
 
44
- // Write to the record's chosen backend (default browser), and clear the other
45
- // so a moved credential leaves no copy behind.
46
48
  write(rec) {
47
- const where = rec.storage || 'browser';
48
- const clean = { ...rec, storage: where };
49
- if (where === 'pod') {
50
- this.store.write(POD_DOC, clean); this.store.flush?.().catch(() => {});
51
- this._idbRec = null; kvDel(IDB_KEY).catch(() => {});
52
- } else {
53
- this._idbRec = clean; kvPut(IDB_KEY, clean).catch((e) => this.log(`atproto persist: ${e.message}`));
54
- this.store.write(POD_DOC, null); this.store.flush?.().catch(() => {});
49
+ this.store.write(POD_DOC, { ...rec, storage: 'pod' }); this.store.flush?.().catch(() => {});
50
+ }
51
+
52
+ // A record still in this browser, onto the pod, and out of the browser once
53
+ // the pod has it. One the pod already holds is newer there.
54
+ async moveToPod() {
55
+ const rec = this._idbRec;
56
+ if (!rec || (rec.mintedFor && this.actorId && rec.mintedFor !== this.actorId)) return false;
57
+ if (!this.store.read(POD_DOC, null)) {
58
+ this.store.write(POD_DOC, { ...rec, storage: 'pod' });
59
+ if (!await this.store.commit()) return false;
55
60
  }
61
+ this._idbRec = null; await kvDel(IDB_KEY).catch(() => {});
62
+ this.log('the Bluesky account moved from this browser to the pod');
63
+ return true;
56
64
  }
57
65
 
58
66
  status() {
@@ -69,8 +77,6 @@ export class BrowserAtproto extends Atproto {
69
77
  feedActive() { return this.connected() && !this.read()?.feedPaused; }
70
78
  setFeedPaused(on) { const rec = this.read(); if (rec) this.write({ ...rec, feedPaused: !!on }); }
71
79
 
72
- // Move the credential between backends (the Options menu's Storage control).
73
- setStorage(where) { const rec = this.read(); if (rec && (where === 'pod' || where === 'browser')) this.write({ ...rec, storage: where }); }
74
80
 
75
81
  async disconnect() {
76
82
  const rec = this.read();
@@ -105,7 +105,8 @@ export class RelayDeliverer extends Deliverer {
105
105
  const status = r0.status || 0;
106
106
  if (status === 0) { const err = new Error(r0.error || 'relay could not send'); err.status = 502; throw err; }
107
107
  if (status >= 400) {
108
- const err = new Error(`${method} ${url} → ${status}`); err.status = status;
108
+ // `answered`: the far server's own answer, as against the relay's.
109
+ const err = new Error(`${method} ${url} → ${status}`); err.status = status; err.answered = true;
109
110
  // The receiving server's own answer to "when should I try again", carried
110
111
  // through the relay (lib/front-core.mjs). Spelled `retryAfterMs`, which is
111
112
  // what the delivery queue reads (lib/deliver.mjs) — the queue's ladder is
@@ -54,6 +54,7 @@ globalThis.Buffer ??= (() => {
54
54
  concat: (list) => { const n = list.reduce((s, x) => s + x.length, 0); const o = new Buf(n); let i = 0; for (const x of list) { o.set(x, i); i += x.length; } return o; },
55
55
  alloc: (n) => new Buf(n),
56
56
  isBuffer: (x) => x instanceof Uint8Array,
57
+ byteLength: (d, enc) => (typeof d === 'string' ? fromString(d, enc).length : d.byteLength ?? d.length),
57
58
  };
58
59
  })();
59
60