fedipod 1.30.0 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/admin/bar.css CHANGED
@@ -62,16 +62,32 @@ body { background: var(--surface); }
62
62
  quieter than the links; the one in use is marked `aria-current="true"` — the
63
63
  current item of a set, which on the record page is not the current PAGE — so
64
64
  it needs its own mark rather than borrowing the rule above. */
65
- #bar #client-pick { display: inline-flex; gap: .6rem; align-items: baseline;
65
+ #bar #client-pick, #bar #account-pick { display: inline-flex; gap: .6rem; align-items: baseline;
66
66
  white-space: nowrap; margin-left: 1rem; }
67
- #bar #client-now { opacity: .75; }
67
+ #bar #client-now, #bar #account-now { opacity: .75; }
68
+ /* The notices bell: the one filled control in the bar, because it is the one
69
+ thing there that is not a place to go but something to be told. The count is
70
+ how many notices this browser has not opened yet; with none it is quiet. */
71
+ #bar #bar-notices { display: inline-flex; align-items: center; gap: .35rem; margin-left: 1rem;
72
+ padding: .3rem .7rem; border-radius: 999px; font-size: 1.05rem; line-height: 1;
73
+ background: var(--btn); color: var(--btn-text); border: 1px solid var(--btn); }
74
+ /* `display` on an id rule outranks the browser's own [hidden] rule, and the
75
+ shell has no !important of its own, so the bell hid on the record page and
76
+ showed on the client with nothing to show. Said here, once, for every page. */
77
+ #bar #bar-notices[hidden] { display: none; }
78
+ #bar #bar-notices:hover { background: var(--btn-hover); border-color: var(--btn-hover); }
79
+ #bar #bar-notices:focus-visible { outline: 3px solid var(--ring); outline-offset: 2px; }
80
+ #bar #bar-notices-count { font-size: .9rem; font-weight: 700; min-width: 1.3em; text-align: center;
81
+ padding: .05rem .4rem; border-radius: 999px; background: var(--btn-text); color: var(--btn); }
82
+ #bar #bar-notices-count:empty { display: none; }
83
+ #bar #bar-notices[data-new="0"] { background: var(--field-bg); color: var(--ink); border-color: var(--field-edge); }
68
84
  #bar a[aria-current="true"] {
69
85
  font-weight: 700; text-decoration: underline; text-underline-offset: .3rem; }
70
86
 
71
87
  /* The two-clients notice: said once, to somebody who knew this page when it
72
88
  had one client. Sized and placed rather than left to the browser's default,
73
89
  which stretched it edge to edge and put it over the very links it names. */
74
- #client-news {
90
+ #client-news, #notices-list, #notice-view {
75
91
  /* Low rather than centred: it points at the links in the bar, so the bar has
76
92
  to stay in view while it is read. */
77
93
  max-width: 34rem; margin: auto auto 2.5rem; padding: 1.5rem 1.7rem;
@@ -80,13 +96,31 @@ body { background: var(--surface); }
80
96
  font: 1rem/1.55 var(--font);
81
97
  box-shadow: 0 .6rem 2rem rgba(0, 0, 0, .25);
82
98
  }
83
- #client-news::backdrop { background: rgba(0, 0, 0, .45); }
84
- #client-news h2 { font-size: 1.25rem; margin: 0 0 .9rem; color: var(--heading); }
85
- #client-news p { margin: 0 0 1.2rem; }
86
- #client-news p:last-child { margin: 0; }
87
- #client-news button {
99
+ #client-news::backdrop, #notices-list::backdrop, #notice-view::backdrop { background: rgba(0, 0, 0, .45); }
100
+ #client-news h2, #notices-list h2, #notice-view h2 { font-size: 1.25rem; margin: 0 0 .9rem; color: var(--heading); }
101
+ #client-news p, #notices-list p, #notice-view p { margin: 0 0 1.2rem; }
102
+ #client-news p:last-child, #notices-list p:last-child, #notice-view p:last-child { margin: 0; }
103
+ #client-news button, #notices-list button, #notice-view button {
88
104
  font: inherit; padding: .55rem 1.3rem; border-radius: .35rem; cursor: pointer;
89
105
  background: var(--btn); color: var(--btn-text); border: 1px solid var(--btn);
90
106
  }
91
- #client-news button:hover { background: var(--btn-hover); border-color: var(--btn-hover); }
92
- #client-news button:focus-visible { outline: 3px solid var(--ring); outline-offset: 2px; }
107
+ #client-news button:hover, #notices-list button:hover, #notice-view button:hover { background: var(--btn-hover); border-color: var(--btn-hover); }
108
+ #client-news button:focus-visible, #notices-list button:focus-visible, #notice-view button:focus-visible { outline: 3px solid var(--ring); outline-offset: 2px; }
109
+
110
+ /* The notices dialogs are centred and never taller than the window: the
111
+ client-news rule above sits low, and a short window pushed a list of
112
+ notices up past the top edge where nothing could reach it. */
113
+ #notices-list, #notice-view { margin: auto; max-height: calc(100dvh - 3rem); overflow: auto; box-sizing: border-box; }
114
+ /* The list of notices: each title a button that opens that notice, the date
115
+ beside it, the ones not yet opened here in bold. */
116
+ #notices-items { list-style: none; margin: 0 0 1.2rem; padding: 0; }
117
+ #notices-items li { display: flex; gap: .8rem; align-items: baseline; padding: .45rem 0;
118
+ border-bottom: 1px solid var(--line); }
119
+ #notices-items li:last-child { border-bottom: 0; }
120
+ #notices-items button.notice-open { flex: 1 1 auto; text-align: left; padding: .1rem 0;
121
+ background: none; border: 0; color: var(--link); text-decoration: underline; text-underline-offset: .2rem; }
122
+ #notices-items li.new button.notice-open { font-weight: 700; }
123
+ #notices-items .when, #notice-view .when { color: var(--sub); font-size: .9rem; white-space: nowrap; }
124
+ #notices-items p.none { color: var(--sub); }
125
+ #notice-view-body p { margin: 0 0 .9rem; }
126
+ #notice-view-body a { color: var(--link); word-break: break-all; }
@@ -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,10 +137,35 @@ 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 {
@@ -179,6 +204,7 @@ export class BrowserAgent {
179
204
  this.viewer = true;
180
205
  this.log('another device took over — read-only here');
181
206
  this.lease.stopRenewal();
207
+ clearInterval(this._openTimer); this._openTimer = null;
182
208
  this.intake?.stop?.();
183
209
  // The delivery queue as well. Its timer starts in the Deliverer's
184
210
  // constructor and nothing here ever switched it off, so a demoted device
@@ -322,6 +348,28 @@ export class BrowserAgent {
322
348
  onGone: () => this.publisher.publishCollections({ followers: true }),
323
349
  });
324
350
 
351
+ // The gateway this account's mail comes through, and the account's own
352
+ // standing there — paused, closed, how much arrived unread (front-core:
353
+ // accounts that go quiet). The gateway is told on every sign-in and
354
+ // hourly while this device is active: that is what keeps an account
355
+ // somebody reads from counting as a quiet one. Only a closed address
356
+ // stops the boot; a gateway that cannot be reached is no reason to
357
+ // refuse a sign-in.
358
+ this.gatewayApi = null;
359
+ try { if (config.gateway?.url) this.gatewayApi = `${new URL(config.gateway.url).origin}/api`; } catch { this.gatewayApi = null; }
360
+ this.doorKey = doorKeyOf(config.gateway?.url) || config.handle;
361
+ this.gatewayStanding = null;
362
+ const standing = await this.openAtGateway();
363
+ if (standing?.status === 410) {
364
+ const host = (() => { try { return new URL(this.gatewayApi).host; } catch { return 'the gateway'; } })();
365
+ const address = this.doorKey.includes('@') ? `@${this.doorKey}` : `@${this.doorKey}@${host}`;
366
+ const why = standing.closedBy === 'owner' ? 'you closed it' : 'nothing opened it for six months';
367
+ const e = new Error(`${address} is closed at ${host}: ${why}, and a closed address does not come back.`
368
+ + ` Everything on your pod is untouched. The sign-in used here was ${webId}.`);
369
+ e.code = 'address-closed';
370
+ throw e;
371
+ }
372
+
325
373
  this.publisher = new Publisher({
326
374
  config: this.store.getConfig(), remote: this.remote, store: this.store,
327
375
  deliverer: this.deliverer, publicKeyPem: keys.rsaPublicPem, assertionKey: null, log: this.log,
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
@@ -33662,6 +33662,7 @@ async function podForFrontedAddress(handle) {
33662
33662
  `/.well-known/webfinger?resource=${encodeURIComponent(`acct:${handle}@${location.host}`)}`,
33663
33663
  { headers: { accept: "application/jrd+json, application/json" } }
33664
33664
  ).catch(() => null);
33665
+ 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.`);
33665
33666
  if (!res || res.status >= 400) throw new Error(`nobody at this site is called @${handle}@${location.host}`);
33666
33667
  const doc = await res.json().catch(() => ({}));
33667
33668
  const podActorId = (doc.aliases || []).find((a) => /\/ap\/actor$/u.test(String(a)));
@@ -33809,7 +33810,8 @@ if (typeof document !== "undefined") (async () => {
33809
33810
  // login and the identity screen lands here.
33810
33811
  "no-account-here": { title: "No FediPod account in that pod", retry: "Create one on this pod", go: () => showIdentity() },
33811
33812
  "no-account": { title: "No FediPod account in that pod", retry: "Create one on this pod", go: () => showIdentity() },
33812
- "device-account": { title: "This account is run from a device", retry: "Use another pod", go: signIn }
33813
+ "device-account": { title: "This account is run from a device", retry: "Use another pod", go: signIn },
33814
+ "address-closed": { title: "This address is closed", retry: "Sign in with another account", go: signIn }
33813
33815
  }[e.code];
33814
33816
  if (e.code === "sign-in-refused" && once("fedipod-renewing")) {
33815
33817
  $("loading").hidden = true;