fedipod 1.30.0 → 1.36.6

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 (43) hide show
  1. package/README.md +1 -1
  2. package/gateway.md +37 -0
  3. package/gui.md +2 -2
  4. package/lib/connections/bskyfeed.mjs +6 -0
  5. package/lib/core/deliver.mjs +52 -22
  6. package/lib/core/intake/index.mjs +17 -4
  7. package/lib/core/lease.mjs +7 -7
  8. package/lib/core/store.mjs +25 -5
  9. package/lib/gateway/caches.mjs +36 -0
  10. package/lib/gateway/front-core.mjs +59 -66
  11. package/lib/gateway/gateway-core.mjs +20 -8
  12. package/lib/gateway/headers.mjs +49 -0
  13. package/lib/gateway/notices.mjs +84 -0
  14. package/lib/gateway/quiet.mjs +189 -0
  15. package/lib/pod/containers.mjs +5 -1
  16. package/lib/pod/inbox.mjs +22 -3
  17. package/lib/pod/transport.mjs +36 -3
  18. package/lib/session/README.md +14 -0
  19. package/lib/session/fedi-account.mjs +62 -0
  20. package/lib/session/package.json +10 -2
  21. package/package.json +1 -1
  22. package/run-agent.mjs +16 -0
  23. package/scripts/stage-site.mjs +6 -3
  24. package/web/admin/bar.css +44 -10
  25. package/web/admin/client/index.html +29 -2
  26. package/web/admin/gateway.js +19 -0
  27. package/web/admin/index.html +46 -3
  28. package/web/admin/notices-bar.js +90 -0
  29. package/web/admin/record.js +1 -0
  30. package/web/admin/setup/index.html +29 -2
  31. package/web/admin/upkeep.js +5 -1
  32. package/web/app/admin-facade.mjs +24 -0
  33. package/web/app/agent.mjs +59 -6
  34. package/web/app/boot.mjs +2 -0
  35. package/web/app/deliver-relay.mjs +47 -7
  36. package/web/app/dist/boot.js +39 -4
  37. package/web/app/dist/boot.js.map +2 -2
  38. package/web/app/dist/sw.js +273 -38
  39. package/web/app/dist/sw.js.map +3 -3
  40. package/web/app/update.js +4 -0
  41. package/web/front/admin.html +3 -1
  42. package/web/front/notices.html +69 -0
  43. package/web/front/notices.js +107 -0
@@ -28,8 +28,16 @@ main { flex: 1 1 auto; min-height: 0; display: flex; }
28
28
  <h1 class="name">FediPod</h1>
29
29
  <select id="actor-pick" aria-label="Local actors"
30
30
  title=" Every actor on this machine; choosing one opens its client"></select>
31
- <a id="bar-fediverse" href="./" title=" Open the client and read your timeline" aria-current="page">visit account</a>
32
- <a id="bar-manage" href="../" title=" The record: what this actor is, and what you can do to it">manage account</a>
31
+ <!-- The account group, styled like the client group beside it: one label,
32
+ then the places to go. The browser build adds "sign out" to it. -->
33
+ <span id="account-pick"><span id="account-now">account:</span>
34
+ <a id="bar-fediverse" href="./" title=" Open the client and read your timeline" aria-current="page">visit</a>
35
+ <a id="bar-manage" href="../" title=" The record: what this actor is, and what you can do to it">manage</a>
36
+ </span>
37
+ <!-- Notices from whoever runs this site. Shown only when the site has any
38
+ (notices-bar.js asks /api/notices); the count is how many are new here. -->
39
+ <button type="button" id="bar-notices" hidden aria-haspopup="dialog" aria-controls="notices-list"
40
+ title=" Notices from the operator of this site">&#128276;<span id="bar-notices-count" aria-live="polite"></span></button>
33
41
  </nav>
34
42
 
35
43
  <main>
@@ -38,5 +46,24 @@ main { flex: 1 1 auto; min-height: 0; display: flex; }
38
46
 
39
47
  <script src="../bar.js"></script>
40
48
  <script src="client.js"></script>
49
+
50
+ <!-- The notices: the list, and one notice open. Declared here and filled by
51
+ notices-bar.js from /api/notices; nothing in them is markup from a notice. -->
52
+ <dialog id="notices-list" aria-labelledby="notices-title">
53
+ <section>
54
+ <h2 id="notices-title">Notices</h2>
55
+ <ul id="notices-items"></ul>
56
+ <p class="row"><button type="button" id="notices-close" class="primary" title=" Close the list of notices">Close</button></p>
57
+ </section>
58
+ </dialog>
59
+ <dialog id="notice-view" aria-labelledby="notice-view-title">
60
+ <section>
61
+ <h2 id="notice-view-title"></h2>
62
+ <p class="when" id="notice-view-when"></p>
63
+ <div id="notice-view-body"></div>
64
+ <p class="row"><button type="button" id="notice-view-close" class="primary" title=" Back to the list of notices">Close</button></p>
65
+ </section>
66
+ </dialog>
67
+ <script src="../notices-bar.js"></script>
41
68
  </body>
42
69
  </html>
@@ -18,12 +18,31 @@ async function refreshGateway() {
18
18
  GATEWAY_WORD.textContent = frontName ? `${host} — publishing as @${frontName}@${host}` : host;
19
19
  GW_OPEN_ATTACH.hidden = true;
20
20
  GW_OPEN_DETACH.hidden = false;
21
+ // The account's standing at the gateway — paused, closed. The browser
22
+ // build's agent reports it; a DeviceAgent's does not, and the pause and
23
+ // close controls stay hidden.
24
+ const st = g.standing;
25
+ const known = !!st && (st.status === 200 || st.status === 410);
26
+ $('gateway-pause').hidden = !known || !!st.closed || !!st.paused;
27
+ $('gateway-resume').hidden = !known || !!st.closed || !st.paused;
28
+ $('gateway-close').hidden = !known || !!st.closed;
29
+ if (known && st.closed) GATEWAY_WORD.textContent += ' — this address is closed';
30
+ else if (known && st.paused) GATEWAY_WORD.textContent += st.pausedBy === 'owner' ? ' — paused by you' : ' — paused';
21
31
  } else {
22
32
  GATEWAY_WORD.textContent = '';
23
33
  GW_OPEN_ATTACH.hidden = false;
24
34
  GW_OPEN_DETACH.hidden = true;
35
+ for (const id of ['gateway-pause', 'gateway-resume', 'gateway-close']) $(id).hidden = true;
25
36
  }
26
37
  }
38
+ const setPausedAtGateway = async (paused) => {
39
+ const r = await write('/gateway/pause', { paused },
40
+ paused ? 'paused — posts sent to you are not kept until you resume; follows still arrive'
41
+ : 'resumed — posts sent to you are kept again');
42
+ if (r) refreshGateway();
43
+ };
44
+ $('gateway-pause').addEventListener('click', () => setPausedAtGateway(true));
45
+ $('gateway-resume').addEventListener('click', () => setPausedAtGateway(false));
27
46
  const gwShape = () => document.querySelector('input[name=gwShape]:checked')?.value || 'pod';
28
47
  function gwPreviews() {
29
48
  $('gw-pod-preview').textContent = config?.address || `@${config?.handle || 'you'}@your.pod`;
@@ -165,7 +165,7 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
165
165
  fieldset { border-color: #555; } .rows li { border-color: #444; }
166
166
  }
167
167
  </style>
168
- <link rel="stylesheet" href="bar.css?v=3">
168
+ <link rel="stylesheet" href="bar.css?v=4">
169
169
  <link rel="stylesheet" href="window.css">
170
170
  </head>
171
171
  <body>
@@ -175,8 +175,16 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
175
175
  <h1 class="name">FediPod</h1>
176
176
  <select id="actor-pick" aria-label="Local actors"
177
177
  title=" Every actor on this machine; choosing one goes to its record"></select>
178
- <a id="bar-fediverse" href="client/" title=" Open the client and read your timeline">visit account</a>
179
- <a id="bar-manage" href="./" title=" The record: what this actor is, and what you can do to it" aria-current="page">manage account</a>
178
+ <!-- The account group, styled like the client group beside it: one label,
179
+ then the places to go. The browser build adds "sign out" to it. -->
180
+ <span id="account-pick"><span id="account-now">account:</span>
181
+ <a id="bar-fediverse" href="client/" title=" Open the client and read your timeline">visit</a>
182
+ <a id="bar-manage" href="./" title=" The record: what this actor is, and what you can do to it" aria-current="page">manage</a>
183
+ </span>
184
+ <!-- Notices from whoever runs this site. Shown only when the site has any
185
+ (notices-bar.js asks /api/notices); the count is how many are new here. -->
186
+ <button type="button" id="bar-notices" hidden aria-haspopup="dialog" aria-controls="notices-list"
187
+ title=" Notices from the operator of this site">&#128276;<span id="bar-notices-count" aria-live="polite"></span></button>
180
188
  </nav>
181
189
  <main id="page">
182
190
 
@@ -321,6 +329,14 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
321
329
  title=" Attach this account to a mail-filtering gateway">Attach to a gateway</button>
322
330
  <button type="button" id="gateway-open-detach" class="inline danger" hidden
323
331
  title=" Go back to your pod's own inbox">Detach from gateway</button>
332
+ <!-- Shown only when the agent reports the account's standing at the gateway
333
+ (the browser build does; a DeviceAgent's admin does not). -->
334
+ <button type="button" id="gateway-pause" class="inline" hidden
335
+ title=" Stop keeping the posts sent to you until you resume; follows still arrive">Pause my account</button>
336
+ <button type="button" id="gateway-resume" class="inline" hidden
337
+ title=" Keep the posts sent to you again">Resume my account</button>
338
+ <button type="button" id="gateway-close" class="inline danger" hidden data-confirm="close-address"
339
+ title=" Close your address at this gateway for good; nothing on your pod is touched">Close this address</button>
324
340
  </span>
325
341
 
326
342
  <section id="pane-group" hidden>
@@ -516,6 +532,14 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
516
532
  <label for="confirm-handle">Type the handle to confirm</label>
517
533
  <input type="text" id="confirm-handle" autocomplete="off">
518
534
  </div>
535
+ <div id="warn-close-address" class="warn" hidden>
536
+ <p><strong>This cannot be undone.</strong> Your address at the gateway is closed for good:
537
+ mail to it is turned away, other servers drop the account the next time they look, and
538
+ nobody can take the name. Nothing on your pod is touched — your posts, key and data stay
539
+ where they are.</p>
540
+ <label for="confirm-handle-close">Type the handle to confirm</label>
541
+ <input type="text" id="confirm-handle-close" autocomplete="off">
542
+ </div>
519
543
  <!-- "Move private data" (POST /state-move) had a panel here and no way to
520
544
  open it: nothing in this page has ever carried data-confirm="move-state",
521
545
  so the panel was only ever hidden. Removed 2026-09-09 rather than left as
@@ -556,5 +580,24 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
556
580
  <script src="group.js"></script>
557
581
  <script src="upkeep.js"></script>
558
582
  <script src="gateway.js"></script>
583
+
584
+ <!-- The notices: the list, and one notice open. Declared here and filled by
585
+ notices-bar.js from /api/notices; nothing in them is markup from a notice. -->
586
+ <dialog id="notices-list" aria-labelledby="notices-title">
587
+ <section>
588
+ <h2 id="notices-title">Notices</h2>
589
+ <ul id="notices-items"></ul>
590
+ <p class="row"><button type="button" id="notices-close" class="primary" title=" Close the list of notices">Close</button></p>
591
+ </section>
592
+ </dialog>
593
+ <dialog id="notice-view" aria-labelledby="notice-view-title">
594
+ <section>
595
+ <h2 id="notice-view-title"></h2>
596
+ <p class="when" id="notice-view-when"></p>
597
+ <div id="notice-view-body"></div>
598
+ <p class="row"><button type="button" id="notice-view-close" class="primary" title=" Back to the list of notices">Close</button></p>
599
+ </section>
600
+ </dialog>
601
+ <script src="notices-bar.js"></script>
559
602
  </body>
560
603
  </html>
@@ -0,0 +1,90 @@
1
+ // notices-bar.js — the notices bell in the bar, on every page of ours.
2
+ //
3
+ // Whoever runs the site writes notices (the /notices page, admin only); every
4
+ // signed-in account sees the bell. This asks /api/notices once per page load,
5
+ // hides the bell where the site has none to offer (a DeviceAgent's own pages
6
+ // answer no such thing), lists the titles, and opens one notice at a time.
7
+ // Which notices this browser has already opened is this browser's business
8
+ // alone, so it lives in localStorage and nowhere else.
9
+ //
10
+ // Every element here is declared in the page's markup; this fills them.
11
+ (() => {
12
+ const bell = document.getElementById('bar-notices');
13
+ const list = document.getElementById('notices-list');
14
+ const view = document.getElementById('notice-view');
15
+ if (!bell || !list || !view) return;
16
+ const $ = (id) => document.getElementById(id);
17
+ const SEEN = 'fedipod-notices-seen';
18
+ const readSeen = () => { try { return JSON.parse(localStorage.getItem(SEEN) || '[]'); } catch { return []; } };
19
+ const writeSeen = (ids) => { try { localStorage.setItem(SEEN, JSON.stringify(ids.slice(-200))); } catch { /* keeps no site data */ } };
20
+
21
+ const when = (iso) => { try { return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } catch { return ''; } };
22
+
23
+ // A notice's body is plain text: paragraphs at blank lines, and a bare
24
+ // https link becomes a link. Nothing in it is ever read as markup.
25
+ const renderBody = (el, text) => {
26
+ el.textContent = '';
27
+ for (const para of String(text || '').split(/\n\s*\n/u)) {
28
+ const p = document.createElement('p');
29
+ for (const part of para.split(/(https?:\/\/[^\s<>"']+)/u)) {
30
+ if (/^https?:\/\//u.test(part)) {
31
+ const a = document.createElement('a');
32
+ a.href = part; a.textContent = part; a.target = '_blank'; a.rel = 'noopener';
33
+ p.append(a);
34
+ } else p.append(part);
35
+ }
36
+ el.append(p);
37
+ }
38
+ };
39
+
40
+ let notices = [];
41
+ const render = () => {
42
+ const seen = new Set(readSeen());
43
+ const fresh = notices.filter((n) => !seen.has(n.id)).length;
44
+ $('bar-notices-count').textContent = fresh ? String(fresh) : '';
45
+ bell.dataset.new = String(fresh);
46
+ bell.setAttribute('aria-label', fresh ? `Notices, ${fresh} new` : 'Notices');
47
+ const ul = $('notices-items');
48
+ ul.textContent = '';
49
+ if (!notices.length) {
50
+ const p = document.createElement('p'); p.className = 'none'; p.textContent = 'No notices.';
51
+ ul.append(p);
52
+ return;
53
+ }
54
+ for (const n of notices) {
55
+ const li = document.createElement('li');
56
+ if (!seen.has(n.id)) li.classList.add('new');
57
+ const b = document.createElement('button');
58
+ b.type = 'button'; b.className = 'notice-open'; b.textContent = n.title;
59
+ b.addEventListener('click', () => open(n));
60
+ const w = document.createElement('span'); w.className = 'when'; w.textContent = when(n.at);
61
+ li.append(b, w);
62
+ ul.append(li);
63
+ }
64
+ };
65
+
66
+ const open = (n) => {
67
+ $('notice-view-title').textContent = n.title;
68
+ $('notice-view-when').textContent = when(n.at);
69
+ renderBody($('notice-view-body'), n.body);
70
+ const seen = readSeen();
71
+ if (!seen.includes(n.id)) { seen.push(n.id); writeSeen(seen); }
72
+ render();
73
+ list.close();
74
+ view.showModal();
75
+ };
76
+
77
+ bell.addEventListener('click', () => { render(); list.showModal(); });
78
+ $('notices-close').addEventListener('click', () => list.close());
79
+ $('notice-view-close').addEventListener('click', () => { view.close(); list.showModal(); });
80
+
81
+ fetch(location.origin + '/api/notices', { headers: { accept: 'application/json' } })
82
+ .then((r) => (r.ok ? r.json() : null))
83
+ .then((j) => {
84
+ if (!j || !Array.isArray(j.notices)) return;
85
+ notices = j.notices;
86
+ render();
87
+ bell.hidden = false;
88
+ })
89
+ .catch(() => { /* no site notices here; the bell stays hidden */ });
90
+ })();
@@ -94,6 +94,7 @@ function resetConfirm() {
94
94
  for (const k of Object.keys(LIFECYCLE)) $(`warn-${k}`)?.hidden !== undefined && ($(`warn-${k}`).hidden = true);
95
95
  $('confirm-handle').value = '';
96
96
  $('confirm-handle-move').value = '';
97
+ $('confirm-handle-close').value = '';
97
98
  $('move-target').value = '';
98
99
  }
99
100
  function closePanels(keep = null) {
@@ -59,8 +59,16 @@ button:disabled { opacity: .5; cursor: default; }
59
59
  <nav id="bar" aria-label="Site">
60
60
  <h1 class="name">FediPod</h1>
61
61
  <span id="bar-handle"></span>
62
- <a id="bar-fediverse" href="../client/" title=" Open the client and read your timeline">visit account</a>
63
- <a id="bar-manage" href="../" title=" The record: what this actor is, and what you can do to it">manage account</a>
62
+ <!-- The account group, styled like the client group beside it: one label,
63
+ then the places to go. The browser build adds "sign out" to it. -->
64
+ <span id="account-pick"><span id="account-now">account:</span>
65
+ <a id="bar-fediverse" href="../client/" title=" Open the client and read your timeline">visit</a>
66
+ <a id="bar-manage" href="../" title=" The record: what this actor is, and what you can do to it">manage</a>
67
+ </span>
68
+ <!-- Notices from whoever runs this site. Shown only when the site has any
69
+ (notices-bar.js asks /api/notices); the count is how many are new here. -->
70
+ <button type="button" id="bar-notices" hidden aria-haspopup="dialog" aria-controls="notices-list"
71
+ title=" Notices from the operator of this site">&#128276;<span id="bar-notices-count" aria-live="polite"></span></button>
64
72
  <a id="bar-add" href="../?new=1" title=" Set up another actor on this machine, with its own pod" aria-current="page">add new account</a>
65
73
  </nav>
66
74
  <main id="page">
@@ -210,5 +218,24 @@ button:disabled { opacity: .5; cursor: default; }
210
218
  <script src="../bar.js"></script>
211
219
  <script src="../common.js"></script>
212
220
  <script src="setup.js"></script>
221
+
222
+ <!-- The notices: the list, and one notice open. Declared here and filled by
223
+ notices-bar.js from /api/notices; nothing in them is markup from a notice. -->
224
+ <dialog id="notices-list" aria-labelledby="notices-title">
225
+ <section>
226
+ <h2 id="notices-title">Notices</h2>
227
+ <ul id="notices-items"></ul>
228
+ <p class="row"><button type="button" id="notices-close" class="primary" title=" Close the list of notices">Close</button></p>
229
+ </section>
230
+ </dialog>
231
+ <dialog id="notice-view" aria-labelledby="notice-view-title">
232
+ <section>
233
+ <h2 id="notice-view-title"></h2>
234
+ <p class="when" id="notice-view-when"></p>
235
+ <div id="notice-view-body"></div>
236
+ <p class="row"><button type="button" id="notice-view-close" class="primary" title=" Back to the list of notices">Close</button></p>
237
+ </section>
238
+ </dialog>
239
+ <script src="../notices-bar.js"></script>
213
240
  </body>
214
241
  </html>
@@ -76,6 +76,9 @@ const LIFECYCLE = {
76
76
  retire: { path: '/retire', title: 'Retire this identity', go: 'Retire it', danger: true, done: (r) => `retired ${r.deletedAt}: Delete delivered to ${r.inboxes} inbox(es)` },
77
77
  move: { path: '/move', title: 'Transfer this account away', go: 'Transfer it', focus: 'move-target',
78
78
  done: (r) => `transferred to ${r.target}: Move delivered to ${r.inboxes} inbox(es), unfollowed ${r.unfollowed}/${r.following}` },
79
+ // The address at the gateway, not the identity: the pod keeps everything.
80
+ 'close-address': { path: '/gateway/close', title: 'Close this address', go: 'Close it', danger: true, focus: 'confirm-handle-close',
81
+ done: (r) => (r.closed ? 'closed — this address is gone for good; everything on your pod is untouched' : 'not closed') },
79
82
  };
80
83
  let pending = null;
81
84
 
@@ -119,7 +122,8 @@ $('confirm-form').addEventListener('submit', async (ev) => {
119
122
  const what = pending;
120
123
  const body = what === 'retire' ? { confirm: $('confirm-handle').value.trim() }
121
124
  : what === 'move' ? { target: $('move-target').value.trim(), confirm: $('confirm-handle-move').value.trim() }
122
- : {};
125
+ : what === 'close-address' ? { confirm: $('confirm-handle-close').value.trim() }
126
+ : {};
123
127
  if (what === 'move' && !body.target) { say('name the account to transfer to', 'err'); return; }
124
128
  $('confirm-go').disabled = true;
125
129
  say(`${what} — this talks to the pod and to other servers, so it takes a moment`);
@@ -33,6 +33,7 @@ export const ADMIN_PATHS = new Set([
33
33
  '/deadletter', '/blocks', '/profiles', '/modqueue', '/log', '/fediacct', '/describe', '/follow',
34
34
  '/atproto', '/atproto/connect', '/atproto/disconnect',
35
35
  '/rebuild', '/move', '/retire', '/inbox/prune', '/park', '/revive', '/takeover',
36
+ '/gateway/pause', '/gateway/close',
36
37
  '/fediacct/connect', '/fediacct/disconnect', '/fediacct/callback',
37
38
  ]);
38
39
 
@@ -138,6 +139,10 @@ export class AdminFacade {
138
139
  configured: !!(g && g.url), url: g?.url || null, webId: g?.webId || null,
139
140
  frontActor: g?.frontActor || null, mode: g?.mode || 'off', hasSecret: !!g?.hmacSecret,
140
141
  stats: a.store.read('gateway-stats.json', { verified: 0, unverified: 0, lastAt: null }),
142
+ // The account's standing at the gateway — paused, closed — as the
143
+ // gateway last said it (agent.mjs tellGateway). The record page
144
+ // shows its pause and close controls only when this is here.
145
+ standing: a.gatewayStanding || null,
141
146
  });
142
147
  }
143
148
  case '/deadletter': return json(200, { items: a.store.getDeadLetters() });
@@ -264,6 +269,25 @@ export class AdminFacade {
264
269
  return json(200, { ok: true, mode: a.status().mode });
265
270
  }
266
271
 
272
+ // The account's standing at its gateway: paused by its owner, or closed
273
+ // for good (front-core: accounts that go quiet). Both go to the gateway
274
+ // with the pod session as proof, and its answer is the new standing.
275
+ case '/gateway/pause': {
276
+ if (typeof body.paused !== 'boolean') return json(400, { error: 'paused must be true or false' });
277
+ if (!a.pauseAtGateway || !a.gatewayApi) return json(501, { error: 'this account is not at a gateway' });
278
+ const r = await a.pauseAtGateway(body.paused);
279
+ if (!r) return json(502, { error: 'the gateway could not be reached' });
280
+ return json(r.status === 200 ? 200 : r.status, r);
281
+ }
282
+ case '/gateway/close': {
283
+ const handle = String(a.store.getConfig()?.handle || '').toLowerCase();
284
+ if (!handle || String(body.confirm || '').toLowerCase() !== handle) return json(400, { error: 'type the handle to confirm' });
285
+ if (!a.closeAtGateway || !a.gatewayApi) return json(501, { error: 'this account is not at a gateway' });
286
+ const r = await a.closeAtGateway();
287
+ if (!r) return json(502, { error: 'the gateway could not be reached' });
288
+ return json(r.status === 200 ? 200 : r.status, r);
289
+ }
290
+
267
291
  // Going quiet, and coming back. The record page's active/parked select.
268
292
  case '/park': {
269
293
  if (!await a.requestTakeover?.()) return json(503, { error: 'another device is active for this pod — park from there' });
package/web/app/agent.mjs CHANGED
@@ -137,19 +137,46 @@ export class BrowserAgent {
137
137
  return true;
138
138
  }
139
139
 
140
+ // One call to the gateway's owner API, proved with the pod session. What
141
+ // comes back on 200 or 410 is the account's standing there, kept for the
142
+ // manage page; anything else is logged and forgotten.
143
+ async tellGateway(what, body) {
144
+ if (!this.gatewayApi) return null;
145
+ let res;
146
+ try {
147
+ res = await this.sessionFetch(`${this.gatewayApi}/${what}`, {
148
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
149
+ });
150
+ } catch (e) { this.log(`gateway ${what}: ${e.message}`); return null; }
151
+ const json = await res.json().catch(() => ({}));
152
+ const out = { status: res.status, ...json };
153
+ if (res.status === 200 || res.status === 410) this.gatewayStanding = out;
154
+ else this.log(`gateway ${what}: ${res.status} ${json.error || ''}`);
155
+ return out;
156
+ }
157
+ openAtGateway() { return this.tellGateway('open', { handle: this.doorKey }); }
158
+ pauseAtGateway(paused) { return this.tellGateway('pause', { handle: this.doorKey, paused: !!paused }); }
159
+ closeAtGateway() { return this.tellGateway('close', { handle: this.doorKey, confirm: true }); }
160
+ static OPEN_EVERY_MS = 60 * 60_000;
161
+
140
162
  // Become the active agent: renew the lease, then start what a viewer skips —
141
163
  // publish the face, drain the inbox, run the mirrors.
142
164
  async goActive() {
143
165
  this.viewer = false;
166
+ // Reading here is being here: the gateway hears so once an hour.
167
+ clearInterval(this._openTimer);
168
+ this._openTimer = setInterval(() => { this.openAtGateway(); }, BrowserAgent.OPEN_EVERY_MS);
144
169
  this.lease.onLost = () => this.demote();
145
170
  this.lease.startRenewal();
146
171
  try {
147
- // Forced, not revalidated. While this device watched, the ACTIVE one was
148
- // writing; our cache is however stale the last viewer poll left it, and
149
- // the store is write-through — so the first write from here would push a
150
- // whole document back over newer state. Read what is actually there
151
- // before acting on it.
152
- await this.store.load({ force: true }).catch((e) => this.log(`re-reading state: ${e.message}`));
172
+ // Forced, not revalidated, when this device WATCHED first: the active
173
+ // one was writing, our cache is however stale the last viewer poll left
174
+ // it, and the store is write-through — so the first write from here
175
+ // would push a whole document back over newer state. A device that
176
+ // booted straight into acting read everything a moment ago; asking
177
+ // again is one revalidation, not a second download of every document.
178
+ await this.store.load({ force: !!this._watched }).catch((e) => this.log(`re-reading state: ${e.message}`));
179
+ this._watched = false;
153
180
  // Own posts the outbox names and the timeline index lacks come back
154
181
  // here, before anything acts on the index.
155
182
  await this.publisher.healStatuses().catch((e) => this.log(`healing the timeline index: ${e.message}`));
@@ -177,8 +204,10 @@ export class BrowserAgent {
177
204
  demote() {
178
205
  if (this.viewer) return;
179
206
  this.viewer = true;
207
+ this._watched = true;
180
208
  this.log('another device took over — read-only here');
181
209
  this.lease.stopRenewal();
210
+ clearInterval(this._openTimer); this._openTimer = null;
182
211
  this.intake?.stop?.();
183
212
  // The delivery queue as well. Its timer starts in the Deliverer's
184
213
  // constructor and nothing here ever switched it off, so a demoted device
@@ -322,6 +351,29 @@ export class BrowserAgent {
322
351
  onGone: () => this.publisher.publishCollections({ followers: true }),
323
352
  });
324
353
 
354
+ // The gateway this account's mail comes through, and the account's own
355
+ // standing there — paused, closed, how much arrived unread (front-core:
356
+ // accounts that go quiet). The gateway is told on every sign-in and
357
+ // hourly while this device is active: that is what keeps an account
358
+ // somebody reads from counting as a quiet one. Only a closed address
359
+ // stops the boot; a gateway that cannot be reached is no reason to
360
+ // refuse a sign-in.
361
+ // At the page's own origin, as the relay is: a page on the test alias
362
+ // asked the real site instead and could not get past its preflight.
363
+ this.gatewayApi = config.gateway?.url ? `${frontOrigin.replace(/\/$/, '')}/api` : null;
364
+ this.doorKey = doorKeyOf(config.gateway?.url) || config.handle;
365
+ this.gatewayStanding = null;
366
+ const standing = await this.openAtGateway();
367
+ if (standing?.status === 410) {
368
+ const host = (() => { try { return new URL(this.gatewayApi).host; } catch { return 'the gateway'; } })();
369
+ const address = this.doorKey.includes('@') ? `@${this.doorKey}` : `@${this.doorKey}@${host}`;
370
+ const why = standing.closedBy === 'owner' ? 'you closed it' : 'nothing opened it for six months';
371
+ const e = new Error(`${address} is closed at ${host}: ${why}, and a closed address does not come back.`
372
+ + ` Everything on your pod is untouched. The sign-in used here was ${webId}.`);
373
+ e.code = 'address-closed';
374
+ throw e;
375
+ }
376
+
325
377
  this.publisher = new Publisher({
326
378
  config: this.store.getConfig(), remote: this.remote, store: this.store,
327
379
  deliverer: this.deliverer, publicKeyPem: keys.rsaPublicPem, assertionKey: null, log: this.log,
@@ -428,6 +480,7 @@ export class BrowserAgent {
428
480
  // The lease decides: this device ACTS on the pod, or reads it read-only.
429
481
  this.viewer = !(await this.lease.acquire());
430
482
  if (this.viewer) {
483
+ this._watched = true;
431
484
  this.log(`read-only viewer: another device is active on @${config.handle}`);
432
485
  this.startViewerPoll(); // reload the feed, and promote if the lease frees
433
486
  return;
package/web/app/boot.mjs CHANGED
@@ -179,6 +179,7 @@ async function issuerForPod(pod) {
179
179
  async function podForFrontedAddress(handle) {
180
180
  const res = await fetch(`/.well-known/webfinger?resource=${encodeURIComponent(`acct:${handle}@${location.host}`)}`,
181
181
  { headers: { accept: 'application/jrd+json, application/json' } }).catch(() => null);
182
+ if (res?.status === 410) throw new Error(`@${handle}@${location.host} is closed: nothing on the pod behind it was touched, but the address is gone for good.`);
182
183
  if (!res || res.status >= 400) throw new Error(`nobody at this site is called @${handle}@${location.host}`);
183
184
  const doc = await res.json().catch(() => ({}));
184
185
  const podActorId = (doc.aliases || []).find((a) => /\/ap\/actor$/u.test(String(a)));
@@ -352,6 +353,7 @@ if (typeof document !== 'undefined') (async () => {
352
353
  'no-account-here': { title: 'No FediPod account in that pod', retry: 'Create one on this pod', go: () => showIdentity() },
353
354
  'no-account': { title: 'No FediPod account in that pod', retry: 'Create one on this pod', go: () => showIdentity() },
354
355
  'device-account': { title: 'This account is run from a device', retry: 'Use another pod', go: signIn },
356
+ 'address-closed': { title: 'This address is closed', retry: 'Sign in with another account', go: signIn },
355
357
  }[e.code];
356
358
 
357
359
  // A sign-in the pod would not take: renew it here, and if the pod will
@@ -10,6 +10,9 @@
10
10
  import { Deliverer } from '../../lib/core/deliver.mjs';
11
11
  import { sign } from './shims/fedify-sig.mjs';
12
12
 
13
+ // What one relay call may carry (lib/gateway/front-core.mjs RELAY_MAX_REQUESTS).
14
+ const RELAY_MAX_REQUESTS = 20;
15
+
13
16
  /**
14
17
  * The name the front keys this account's row by, read off its door inbox:
15
18
  * `<front>/u/<key>/ap/inbox/`. A mail-door account is keyed by its full
@@ -30,6 +33,7 @@ export class RelayDeliverer extends Deliverer {
30
33
  this.relayUrl = opts.relayUrl; // <front>/api/relay
31
34
  this.handle = opts.handle;
32
35
  this.sessionFetch = opts.sessionFetch; // the DPoP session's fetch, to authenticate to the relay
36
+ this.batchSize = RELAY_MAX_REQUESTS;
33
37
  }
34
38
 
35
39
  // Same contract as Deliverer.signedFetch, DEFAULT INCLUDED: an init with no
@@ -40,32 +44,68 @@ export class RelayDeliverer extends Deliverer {
40
44
  // never saw the document it asked for: a Follow from anyone new was rejected
41
45
  // with "actor fetch failed", and nothing needing a lookup could be ingested.
42
46
  async signedFetch(url, init = {}) {
47
+ const req = await this._signedRequest(url, init);
48
+ const [r0] = await this._relay([req]);
49
+ return this._outcome(r0, url, init.method || 'GET');
50
+ }
51
+
52
+ // A fan-out in one call: the relay takes a list, so a post to twenty
53
+ // followers is one call, not twenty (Deliverer.deliverToAll, batchSize).
54
+ async deliverManyNow(targets) {
55
+ const reqs = await Promise.all(targets.map((t) => this._signedRequest(t.inbox, {
56
+ method: 'POST', headers: { 'content-type': 'application/activity+json' }, body: JSON.stringify(t.activity),
57
+ })));
58
+ let results;
59
+ try { results = await this._relay(reqs); } catch (error) { return targets.map(() => ({ error })); }
60
+ return targets.map((t, i) => {
61
+ try { this._outcome(results[i] || {}, t.inbox, 'POST'); return { ok: true }; }
62
+ catch (error) { return { error }; }
63
+ });
64
+ }
65
+
66
+ // Signed here, sent verbatim by the relay. Every signed header goes along,
67
+ // `accept` included: the signature covers it, so a relay request missing it
68
+ // carries an invalid signature — and a read without it gets the HTML page
69
+ // instead of the document.
70
+ async _signedRequest(url, init = {}) {
43
71
  const body = typeof init.body === 'string' ? init.body : (init.body ? new TextDecoder().decode(init.body) : '');
44
72
  const s = await sign({ url, method: init.method || 'GET', headers: init.headers || {}, body }, this.rsaPrivate, this.keyId);
45
- // Every signed header goes to the relay, `accept` included: the signature
46
- // covers it, so a relay request missing it carries an invalid signature —
47
- // and a read without it gets the HTML page instead of the document.
48
- const relayReq = {
73
+ return {
49
74
  url: s.url, method: s.method, body,
50
75
  headers: {
51
76
  date: s.headers.date, digest: s.headers.digest, accept: s.headers.accept,
52
77
  'content-type': s.headers['content-type'], signature: s.headers.signature,
53
78
  },
54
79
  };
80
+ }
81
+
82
+ // One relay call for a list of requests; the results in the same order.
83
+ // The relay's OWN answer, apart from the recipients': unreachable and a
84
+ // refusal are hiccups the queue retries. Its 404 is not — it says this
85
+ // account has no row here, and no retry changes that. It used to be read as
86
+ // a hiccup too, and a tab whose account the site did not know retried its
87
+ // deliveries every minute for three days.
88
+ async _relay(requests) {
55
89
  let res;
56
90
  try {
57
91
  res = await this.sessionFetch(this.relayUrl, {
58
92
  method: 'POST', headers: { 'content-type': 'application/json' },
59
- body: JSON.stringify({ handle: this.handle, requests: [relayReq] }),
93
+ body: JSON.stringify({ handle: this.handle, requests }),
60
94
  });
61
95
  } catch (e) { const err = new Error(`relay unreachable: ${e.message}`); err.status = 0; throw err; }
96
+ if (res.status === 404) { const err = new Error('relay: no such account here'); err.status = 404; throw err; }
62
97
  if (res.status >= 400) { const err = new Error(`relay ${res.status}`); err.status = 502; throw err; }
63
98
  const out = await res.json().catch(() => ({}));
64
- const r0 = (out.results && out.results[0]) || {};
99
+ return Array.isArray(out.results) ? out.results : [];
100
+ }
101
+
102
+ // What the far server answered, as the Node deliverer would have seen it:
103
+ // a Response for a read, a thrown error carrying the status for a refusal.
104
+ _outcome(r0, url, method) {
65
105
  const status = r0.status || 0;
66
106
  if (status === 0) { const err = new Error(r0.error || 'relay could not send'); err.status = 502; throw err; }
67
107
  if (status >= 400) {
68
- const err = new Error(`${init.method || 'POST'} ${url} → ${status}`); err.status = status;
108
+ const err = new Error(`${method} ${url} → ${status}`); err.status = status;
69
109
  // The receiving server's own answer to "when should I try again", carried
70
110
  // through the relay (lib/front-core.mjs). Spelled `retryAfterMs`, which is
71
111
  // what the delivery queue reads (lib/deliver.mjs) — the queue's ladder is