fedipod-server 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +22 -6
  2. package/dist/claims.d.ts +8 -0
  3. package/dist/claims.js +10 -0
  4. package/dist/handler.d.ts +13 -0
  5. package/dist/handler.js +56 -11
  6. package/dist/handler.jsonld +8 -0
  7. package/dist/store-pod.js +18 -4
  8. package/lib/{c2s.mjs → client/c2s.mjs} +10 -3
  9. package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
  10. package/lib/client/masto/accounts.mjs +264 -0
  11. package/lib/client/masto/body.mjs +69 -0
  12. package/lib/client/masto/index.mjs +183 -0
  13. package/lib/client/masto/instance.mjs +104 -0
  14. package/lib/client/masto/media.mjs +133 -0
  15. package/lib/client/masto/oauth.mjs +599 -0
  16. package/lib/client/masto/render.mjs +459 -0
  17. package/lib/client/masto/statuses.mjs +331 -0
  18. package/lib/client/masto/timelines.mjs +316 -0
  19. package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
  20. package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
  21. package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
  22. package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
  23. package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
  24. package/lib/{import.mjs → connections/import.mjs} +1 -1
  25. package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +3 -3
  26. package/lib/connections/vault.mjs +114 -0
  27. package/lib/core/as2.mjs +170 -0
  28. package/lib/core/contexts/activitystreams.json +379 -0
  29. package/lib/core/contexts/did-v1.json +57 -0
  30. package/lib/core/contexts/fep-5711.json +36 -0
  31. package/lib/core/contexts/gotosocial.json +86 -0
  32. package/lib/core/contexts/identity-v1.json +152 -0
  33. package/lib/core/contexts/index.mjs +45 -0
  34. package/lib/core/contexts/join-lemmy.json +33 -0
  35. package/lib/core/contexts/joinmastodon.json +28 -0
  36. package/lib/core/contexts/map.json +16 -0
  37. package/lib/core/contexts/miscellany.json +19 -0
  38. package/lib/core/contexts/schemaorg.json +8845 -0
  39. package/lib/core/contexts/security-data-integrity-v1.json +78 -0
  40. package/lib/core/contexts/security-data-integrity-v2.json +81 -0
  41. package/lib/core/contexts/security-multikey-v1.json +35 -0
  42. package/lib/core/contexts/security-v1.json +74 -0
  43. package/lib/core/contexts/webfinger.json +10 -0
  44. package/lib/{deliver.mjs → core/deliver.mjs} +2 -2
  45. package/lib/core/graphview.mjs +269 -0
  46. package/lib/core/intake/activities.mjs +437 -0
  47. package/lib/core/intake/activity.mjs +240 -0
  48. package/lib/core/intake/channel.mjs +144 -0
  49. package/lib/core/intake/group.mjs +222 -0
  50. package/lib/core/intake/index.mjs +629 -0
  51. package/lib/core/intake/notes.mjs +288 -0
  52. package/lib/core/intake/verify.mjs +142 -0
  53. package/lib/{keys.mjs → core/keys.mjs} +1 -1
  54. package/lib/core/publisher/collections.mjs +229 -0
  55. package/lib/core/publisher/index.mjs +421 -0
  56. package/lib/core/publisher/notes.mjs +188 -0
  57. package/lib/core/publisher/questions.mjs +233 -0
  58. package/lib/core/publisher/restore.mjs +199 -0
  59. package/lib/core/shapes/activitystreams.ttl +129 -0
  60. package/lib/core/shapes/index.mjs +107 -0
  61. package/lib/core/shapes/shapes-text.mjs +13 -0
  62. package/lib/{social.mjs → core/social.mjs} +2 -2
  63. package/lib/{store.mjs → core/store.mjs} +4 -0
  64. package/lib/{wire.mjs → core/wire.mjs} +2 -2
  65. package/lib/device/admin/index.mjs +13 -0
  66. package/lib/device/admin/origins.mjs +35 -0
  67. package/lib/device/admin/routes/connections.mjs +144 -0
  68. package/lib/device/admin/routes/gateway.mjs +199 -0
  69. package/lib/device/admin/routes/lifecycle.mjs +191 -0
  70. package/lib/device/admin/routes/owner.mjs +322 -0
  71. package/lib/device/admin/routes/setup.mjs +393 -0
  72. package/lib/device/admin/routes/social.mjs +188 -0
  73. package/lib/device/admin/server.mjs +95 -0
  74. package/lib/device/admin/static.mjs +244 -0
  75. package/lib/device/admin/surface.mjs +274 -0
  76. package/lib/device/cli/commands/account.mjs +586 -0
  77. package/lib/device/cli/commands/run.mjs +278 -0
  78. package/lib/device/cli/commands/service.mjs +221 -0
  79. package/lib/device/cli/commands/setup.mjs +410 -0
  80. package/lib/device/cli/commands/state.mjs +559 -0
  81. package/lib/device/cli/context.mjs +288 -0
  82. package/lib/{migrate.mjs → device/migrate.mjs} +1 -1
  83. package/lib/{remote.mjs → device/remote.mjs} +3 -3
  84. package/lib/{setup.mjs → device/setup.mjs} +3 -3
  85. package/lib/{update.mjs → device/update.mjs} +1 -1
  86. package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
  87. package/lib/{front-core.mjs → gateway/front-core.mjs} +3 -3
  88. package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +1 -1
  89. package/lib/{httpsig.mjs → gateway/httpsig.mjs} +1 -1
  90. package/lib/server/embed.mjs +405 -0
  91. package/lib/{links.mjs → shared/links.mjs} +1 -1
  92. package/lib/{ua.mjs → shared/ua.mjs} +1 -1
  93. package/package.json +1 -1
  94. package/run-agent.mjs +33 -25
  95. package/web/admin/actors.js +145 -0
  96. package/web/admin/common.js +23 -0
  97. package/web/admin/connections.js +112 -0
  98. package/web/admin/gateway.js +111 -0
  99. package/web/admin/group.js +258 -0
  100. package/web/admin/index.html +7 -1
  101. package/web/admin/record.js +378 -0
  102. package/web/admin/setup/index.html +1 -0
  103. package/web/admin/setup/setup.js +2 -13
  104. package/web/admin/upkeep.js +170 -0
  105. package/web/app/README.md +6 -6
  106. package/web/app/admin-facade.mjs +3 -3
  107. package/web/app/agent.mjs +14 -16
  108. package/web/app/atproto-browser.mjs +1 -1
  109. package/web/app/boot.mjs +2 -3
  110. package/web/app/deliver-relay.mjs +1 -1
  111. package/web/app/dist/boot.js +22 -3
  112. package/web/app/dist/boot.js.map +2 -2
  113. package/web/app/dist/sw.js +21913 -5446
  114. package/web/app/dist/sw.js.map +4 -4
  115. package/web/app/fediacct-browser.mjs +1 -1
  116. package/web/app/keys-browser.mjs +27 -4
  117. package/web/app/shims/shapes-text.mjs +8 -0
  118. package/web/app/signup.mjs +2 -3
  119. package/web/app/site/admin/actors.js +145 -0
  120. package/web/app/site/admin/common.js +23 -0
  121. package/web/app/site/admin/connections.js +112 -0
  122. package/web/app/site/admin/gateway.js +111 -0
  123. package/web/app/site/admin/group.js +258 -0
  124. package/web/app/site/admin/index.html +7 -1
  125. package/web/app/site/admin/record.js +378 -0
  126. package/web/app/site/admin/setup/index.html +1 -0
  127. package/web/app/site/admin/setup/setup.js +2 -13
  128. package/web/app/site/admin/upkeep.js +170 -0
  129. package/web/app/site/boot.js +22 -3
  130. package/web/app/site/sw.js +21913 -5446
  131. package/web/app/sw-src.mjs +17 -2
  132. package/lib/admin.mjs +0 -1913
  133. package/lib/embed.mjs +0 -220
  134. package/lib/intake.mjs +0 -1981
  135. package/lib/mastoapi.mjs +0 -2284
  136. package/lib/publisher.mjs +0 -1192
  137. package/web/admin/admin.js +0 -1181
  138. package/web/app/site/admin/admin.js +0 -1181
  139. /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
  140. /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
  141. /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
  142. /package/lib/{lease.mjs → core/lease.mjs} +0 -0
  143. /package/lib/{polls.mjs → core/polls.mjs} +0 -0
  144. /package/lib/{proof.mjs → core/proof.mjs} +0 -0
  145. /package/lib/{storage.mjs → core/storage.mjs} +0 -0
  146. /package/lib/{account.mjs → device/account.mjs} +0 -0
  147. /package/lib/{certs.mjs → device/certs.mjs} +0 -0
  148. /package/lib/{export-collections.mjs → device/export-collections.mjs} +0 -0
  149. /package/lib/{home.mjs → device/home.mjs} +0 -0
  150. /package/lib/{ports.mjs → device/ports.mjs} +0 -0
  151. /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
  152. /package/lib/{safefetch.mjs → shared/safefetch.mjs} +0 -0
@@ -0,0 +1,378 @@
1
+ // record.js — the record itself: what the page holds of the config, the
2
+ // messages it says, every write, the load, the panels, and the rows of the
3
+ // facts list with the identity rows and their controls.
4
+ // admin.js — the record, as a page. Everything here is an existing admin
5
+ // route; the CLI's own group commands are thin clients over the same ones.
6
+ //
7
+ // Two rules the routes enforce and this page respects: a write is a MERGE, so a
8
+ // form that never mentions a field cannot delete it; and anything on the wire is
9
+ // not real until the actor is republished, which POST /config does for itself.
10
+ //
11
+ // The UI password is deliberately not here — `fedipod passwd` sets it. It
12
+ // only gates /oauth/authorize, so it does nothing for a loopback-only agent.
13
+
14
+ let config = null;
15
+ // Held, because render() moves it into a generated row and empties that row's
16
+ // list on the next pass — after which getElementById would not find it again.
17
+ const MODERATION = $('moderation');
18
+ const STATUS = $('status-ctl');
19
+ const STATUS_PICK = $('status-pick'); // held: getElementById can't see it mid-render
20
+ const IDENT_CTL = $('ident-ctl');
21
+ const FOLLOWS_CTL = $('follows-ctl');
22
+ const FOLLOWS_PICK = $('follows-pick');
23
+ const UPDATE_CTL = $('update-ctl');
24
+ const UPDATE_WORD = $('update-word');
25
+ const UPDATE_GO = $('update-go');
26
+
27
+ // #say and #fatal live in the accessibility tree from load and hide by being
28
+ // empty (see the stylesheet). Unhiding a live region and filling it in the same
29
+ // task is the classic way to get no announcement at all.
30
+ function say(text, cls = 'ok') {
31
+ const el = $('say');
32
+ el.className = cls;
33
+ // Clear first, then set on the next task: a live region that is handed the
34
+ // same text it already holds announces nothing, so two identical messages in
35
+ // a row ("nothing changed", "nothing changed") would be silent the second time.
36
+ el.textContent = '';
37
+ setTimeout(() => { el.textContent = text; }, 30);
38
+ }
39
+
40
+ // Every write goes through here, so nothing silently half-succeeds.
41
+ async function write(path, body, done) {
42
+ const { status, json } = await postJson(path, body);
43
+ if (status >= 400) { say(json?.error || `refused (HTTP ${status})`, 'err'); return null; }
44
+ say(done);
45
+ return json;
46
+ }
47
+
48
+ UPDATE_GO.onclick = async () => {
49
+ UPDATE_GO.disabled = true;
50
+ const r = await write('/update', {}, 'updating — the agents restart when it finishes; reload in a moment');
51
+ if (!r) UPDATE_GO.disabled = false;
52
+ };
53
+
54
+ async function load() {
55
+ const { status, json } = await api('/config');
56
+ if (status === 409) {
57
+ $('fatal').textContent = 'This agent has no identity yet.';
58
+ const a = document.createElement('a');
59
+ a.href = '/admin/setup/';
60
+ a.textContent = ' Set it up.';
61
+ $('fatal').appendChild(a);
62
+ return;
63
+ }
64
+ if (status !== 200 || !json) {
65
+ $('fatal').textContent = json?.error || `could not read the record (HTTP ${status})`;
66
+ return;
67
+ }
68
+ config = json;
69
+ render();
70
+ if (new URLSearchParams(location.search).has('new')) openNewActor();
71
+ }
72
+
73
+ // Every disclosure on the page — a form, the log, a lifecycle confirmation —
74
+ // is a panel declared inside the floating window and shown by window.js. It
75
+ // used to be an accordion at the foot of the page, which pushed whatever you
76
+ // were reading out from under you and could only ever show one thing.
77
+ //
78
+ // The reset lives here rather than in window.js: the window knows how to show a
79
+ // panel, not what any of them mean.
80
+ function resetConfirm() {
81
+ pending = null;
82
+ for (const k of Object.keys(LIFECYCLE)) $(`warn-${k}`)?.hidden !== undefined && ($(`warn-${k}`).hidden = true);
83
+ $('confirm-handle').value = '';
84
+ $('confirm-handle-move').value = '';
85
+ $('move-target').value = '';
86
+ $('rotate-password').value = '';
87
+ }
88
+ function closePanels(keep = null) {
89
+ if (!keep) solWindow.close();
90
+ if (keep !== 'output') outputSource = null;
91
+ if (keep !== 'confirm-form') resetConfirm();
92
+ }
93
+ // Closing it by the ✕ or by Escape has to clear the same state a Cancel does.
94
+ document.getElementById('win').addEventListener('win:closed', () => {
95
+ outputSource = null;
96
+ resetConfirm();
97
+ $('bsky-password').value = ''; // never leave a secret in a closed panel
98
+ });
99
+
100
+
101
+ // One row per identity connected elsewhere, indented under the row that adds
102
+ // them. Each carries its own way out: disconnecting one has nothing to do with
103
+ // the others.
104
+ function identityRows() {
105
+ const out = [];
106
+ const row = (label, ...nodes) => {
107
+ const dt = document.createElement('dt');
108
+ dt.className = 'under';
109
+ dt.textContent = label;
110
+ const dd = document.createElement('dd');
111
+ dd.append(...nodes);
112
+ out.push([dt, dd]);
113
+ };
114
+ // One descriptor shape for every connected account — Bluesky and each
115
+ // fediverse account — so the row and its Options menu build the same way.
116
+ const accounts = [];
117
+ if (config.atproto?.connected) accounts.push({
118
+ label: 'Bluesky', handle: `@${config.atproto.handle}`,
119
+ href: `https://bsky.app/profile/${config.atproto.handle}`,
120
+ storage: config.atproto.storage || null,
121
+ feedPaused: 'feedPaused' in config.atproto ? config.atproto.feedPaused : null,
122
+ crossPost: 'crossPost' in config.atproto ? config.atproto.crossPost : null,
123
+ feed: (paused) => ['/atproto', { feedPaused: paused }],
124
+ cross: (on) => ['/atproto', { crossPost: on }],
125
+ store: (w) => ['/atproto', { storage: w }],
126
+ off: () => ['/atproto/disconnect', {}],
127
+ });
128
+ for (const acct of config.fediAccounts || []) accounts.push({
129
+ label: acct.host,
130
+ handle: acct.needsReconnect ? `${acct.handle} — sign in again` : acct.handle,
131
+ warn: acct.needsReconnect,
132
+ storage: acct.storage || null,
133
+ feedPaused: !acct.enabled,
134
+ crossPost: null,
135
+ feed: (paused) => ['/fediacct', { id: acct.id, enabled: !paused }],
136
+ store: (w) => ['/fediacct', { id: acct.id, storage: w }],
137
+ off: () => ['/fediacct/disconnect', { id: acct.id }],
138
+ });
139
+ for (const acct of accounts) {
140
+ const name = document.createElement(acct.href ? 'a' : 'span');
141
+ name.textContent = acct.handle;
142
+ if (acct.href) { name.href = acct.href; name.target = '_blank'; name.rel = 'noopener'; name.title = ' Open this account elsewhere in a new tab'; }
143
+ if (acct.warn) name.className = 'warn';
144
+ // Where storage is a real choice (every browser-build account today), two
145
+ // dropdowns sit in the row itself. Where it isn't (the Node agent's
146
+ // Bluesky, which shows a cross-post toggle instead), the Options
147
+ // disclosure it always has is unchanged.
148
+ row(acct.label, name, ' ', ...(acct.storage ? connectionControls(acct) : [optionsMenu(acct)]));
149
+ }
150
+ return out;
151
+ }
152
+
153
+ // Connected, Paused or Disconnected, and where the key lives — one dropdown
154
+ // each, in the row. Replaces the separate feed and connect dropdowns that used
155
+ // to sit behind an Options disclosure; only reached where acct.storage is set.
156
+ function connectionControls(acct) {
157
+ const send = async ([path, payload], msg) => { if (await write(path, payload, msg)) await load(); };
158
+ const pick = (opts, current, onChange, title) => {
159
+ const el = document.createElement('select');
160
+ if (title) el.title = title;
161
+ for (const [v, t] of opts) { const o = document.createElement('option'); o.value = v; o.textContent = t; o.selected = v === current; el.appendChild(o); }
162
+ el.addEventListener('change', () => onChange(el.value));
163
+ return el;
164
+ };
165
+ const conn = pick(
166
+ [['connected', 'Connected'], ['paused', 'Paused'], ['disconnected', 'Disconnected']],
167
+ acct.feedPaused ? 'paused' : 'connected',
168
+ (v) => (v === 'disconnected'
169
+ ? send(acct.off(), 'disconnected')
170
+ : send(acct.feed(v === 'paused'), v === 'paused' ? 'paused adding to your feed' : 'added to your home feed')),
171
+ ' Connected feeds this account into your home feed here; Paused keeps the account but stops adding its posts; Disconnected removes it.');
172
+ const storage = pick(
173
+ [['pod', 'Store key on Pod'], ['browser', 'Store key in Browser']],
174
+ acct.storage,
175
+ (v) => {
176
+ 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; }
177
+ send(acct.store(v), v === 'pod' ? 'stored on your pod' : 'stored in this browser');
178
+ },
179
+ ' 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.');
180
+ const wrap = document.createElement('span');
181
+ wrap.className = 'conn-controls';
182
+ wrap.append(conn, ' ', storage);
183
+ return [wrap];
184
+ }
185
+
186
+ // Every connected account without a storage choice carries an Options menu
187
+ // instead: pause its feed, its cross-post toggle (the Node agent's Bluesky),
188
+ // disconnect.
189
+ function optionsMenu(acct) {
190
+ const d = document.createElement('details');
191
+ d.className = 'opts';
192
+ const sum = document.createElement('summary');
193
+ sum.textContent = 'Options';
194
+ d.appendChild(sum);
195
+ const body = document.createElement('div');
196
+ d.appendChild(body);
197
+
198
+ // Each control is one dropdown whose options ARE the states, so it needs no
199
+ // separate label — the chosen option reads as the current state.
200
+ const pick = (opts, current, onChange) => {
201
+ const el = document.createElement('select');
202
+ for (const [v, t] of opts) { const o = document.createElement('option'); o.value = v; o.textContent = t; o.selected = v === current; el.appendChild(o); }
203
+ el.addEventListener('change', () => onChange(el.value));
204
+ body.appendChild(el);
205
+ };
206
+ const send = async ([path, payload], msg) => { if (await write(path, payload, msg)) await load(); };
207
+
208
+ // Feed: shown in / hidden from the feed you read here.
209
+ if (acct.feedPaused !== null) {
210
+ pick([['show', 'Add to home feed'], ['hide', 'Pause adding to home feed']], acct.feedPaused ? 'hide' : 'show',
211
+ (v) => send(acct.feed(v === 'hide'), v === 'hide' ? 'paused adding to your feed' : 'added to your home feed'));
212
+ }
213
+ // Storage where the browser offers the choice; the Node agent's Bluesky shows
214
+ // its cross-post toggle in the same slot instead.
215
+ if (acct.storage) {
216
+ pick([['browser', 'Store key in Browser'], ['pod', 'Store key on Pod']], acct.storage, (v) => {
217
+ 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; }
218
+ send(acct.store(v), v === 'pod' ? 'stored on your pod' : 'stored in this browser');
219
+ });
220
+ } else if (acct.crossPost !== null) {
221
+ pick([['on', 'Cross-post On'], ['off', 'Cross-post Off']], acct.crossPost ? 'on' : 'off',
222
+ (v) => send(acct.cross(v === 'on'), ''));
223
+ }
224
+ // Connection: staying connected, or disconnecting.
225
+ pick([['connected', 'Connected'], ['disconnect', 'Disconnect']], 'connected',
226
+ (v) => { if (v === 'disconnect') send(acct.off(), 'disconnected'); else load(); });
227
+
228
+ // The storage consequence, at the bottom of the box under every dropdown.
229
+ if (acct.storage) {
230
+ const hint = document.createElement('p'); hint.className = 'opt-hint';
231
+ hint.textContent = acct.storage === 'pod'
232
+ ? 'Follows you to every browser you sign in from; a full-access token lives on your pod.'
233
+ : 'Stays in this browser; the token never leaves this device.';
234
+ body.appendChild(hint);
235
+ }
236
+ return d;
237
+ }
238
+
239
+ function render() {
240
+ // The bar names the actor, the same way on every page. Only the tab title is
241
+ // this page's own business.
242
+ document.title = `FediPod — ${config.handle}`;
243
+ const facts = $('facts');
244
+ facts.textContent = '';
245
+ // What this actor IS comes first. The handle is already in the address beside
246
+ // the heading; the pod, the issuer and the actor URL all read off the WebID;
247
+ // and where the private half sits is not something you act on from here.
248
+ const origins = config.origins || {};
249
+ // Two of these are addresses of things you can open, so they are links. The
250
+ // fediverse one goes to this actor's own page in the client, same origin, so
251
+ // it stays in the tab. The Solid one leaves for the pod, so it does not.
252
+ const rows = [
253
+ ['kind', config.kind ? config.kind[0].toUpperCase() + config.kind.slice(1) : config.kind],
254
+ // The value is the select itself — what it shows IS the state, and
255
+ // changing the word is the whole action, like the moderation controls.
256
+ ['status', 'ctl'],
257
+ ['Fediverse identity', config.address || `@${config.handle} — no resolvable address`,
258
+ config.accountId && config.address ? { href: `/admin/client/#/a/${config.accountId}` } : null],
259
+ ['Solid identity', config.webId, config.remotePod ? { href: config.remotePod, blank: true } : null],
260
+ ['Other identities', 'ctl'],
261
+ ['local store', config.home],
262
+ // The address you actually open, not the bare number — the named origin when
263
+ // there is one, since that is what the client and the OAuth redirect use. An
264
+ // agent with no local host of its own (embedded in a pod server, or in a
265
+ // browser) sends none, and then there is no row rather than a bare number.
266
+ ['local host', (origins.named || origins.loopback || '').replace(/\/$/, '') || null],
267
+ ];
268
+ if (config.version || config.update || config.pendingUpgrade?.length) rows.push(['software', 'ctl']);
269
+ rows.push(['gateway', 'ctl']);
270
+ if (config.quiescedAt) rows.push(['parked since', config.quiescedAt]);
271
+ if (config.movedTo) rows.push(['moved to', config.movedTo]);
272
+ // A person gates followers here; a group's gate is the joins control on its
273
+ // kind row, so the row would be a second switch for the same thing.
274
+ if (config.kind !== 'group') rows.splice(2, 0, ['new followers', 'ctl']);
275
+ // Rows that belong under the one being written, appended after it.
276
+ let kids = null;
277
+ for (const [k, v, link] of rows) {
278
+ if (!v) continue;
279
+ const dt = document.createElement('dt');
280
+ dt.textContent = k;
281
+ const dd = document.createElement('dd');
282
+ if (link) {
283
+ const a = document.createElement('a');
284
+ a.href = link.href;
285
+ a.textContent = v;
286
+ if (link.blank) {
287
+ a.target = '_blank';
288
+ // noopener because the pod is another origin: without it the page we
289
+ // open gets a handle on this one through window.opener.
290
+ a.rel = 'noopener';
291
+ a.title = ` Open ${link.href} in a new tab`;
292
+ } else {
293
+ a.title = ` This actor's page in our client`;
294
+ }
295
+ dd.appendChild(a);
296
+ } else {
297
+ dd.textContent = v;
298
+ }
299
+ // What a group can be moderated into is a property of BEING a group, so the
300
+ // controls sit on the row that says so. Moved rather than built here: the
301
+ // page still declares them, and `facts` is emptied on every render.
302
+ if (k === 'kind' && config.kind === 'group') dd.append(MODERATION);
303
+ if (k === 'status') {
304
+ dd.textContent = '';
305
+ dd.append(STATUS);
306
+ STATUS.hidden = false;
307
+ renderStatus();
308
+ }
309
+ if (k === 'new followers') {
310
+ dd.textContent = '';
311
+ dd.append(FOLLOWS_CTL);
312
+ FOLLOWS_CTL.hidden = false;
313
+ FOLLOWS_PICK.value = config.autoAcceptFollows ? 'auto' : 'approve';
314
+ }
315
+ if (k === 'gateway') {
316
+ dd.textContent = '';
317
+ dd.append(GATEWAY_CTL);
318
+ refreshGateway();
319
+ }
320
+ if (k === 'software') {
321
+ dd.textContent = '';
322
+ dd.append(UPDATE_CTL);
323
+ UPDATE_CTL.hidden = false;
324
+ const u = config.update;
325
+ // The version this agent is running, never the one sitting in the
326
+ // checkout — saying otherwise would name a version nobody is serving.
327
+ const running = config.version || u?.current || null;
328
+ const words = [];
329
+ if (running) words.push(u?.available ? `FediPod ${running} — ${u.latest} available` : `FediPod ${running}`);
330
+ if (config.versionOnDisk && running && config.versionOnDisk !== running)
331
+ words.push(`${config.versionOnDisk} is on disk — restart to run it`);
332
+ if (config.pendingUpgrade?.length) words.push('older data layout — run `fedipod upgrade` in a terminal');
333
+ UPDATE_WORD.textContent = words.join('; ');
334
+ UPDATE_GO.hidden = !u?.available;
335
+ }
336
+ // The way to add one. Each account already connected is a row of its own
337
+ // underneath, so this row stays the action and never becomes a list.
338
+ if (k === 'Other identities') {
339
+ dd.textContent = '';
340
+ dd.append(IDENT_CTL);
341
+ IDENT_CTL.hidden = false;
342
+ kids = identityRows();
343
+ }
344
+ facts.append(dt, dd);
345
+ if (kids) {
346
+ for (const [ckt, ckd] of kids) facts.append(ckt, ckd);
347
+ kids = null;
348
+ }
349
+ }
350
+ if (!config.address) {
351
+ const p = document.createElement('p');
352
+ p.className = 'warn';
353
+ p.textContent = `${config.remotePod} is not the root of its own host, so this actor cannot be `
354
+ + 'discovered as a handle by other servers. Posting and reading still work.';
355
+ $('pane-identity').appendChild(p);
356
+ }
357
+
358
+ // pane-others carries the create control too, so it appears even when this is
359
+ // the only actor and even if /profiles cannot be read.
360
+ for (const id of ['pane-others', 'pane-identity', 'rail']) $(id).hidden = false;
361
+ renderAliases();
362
+ renderOthers();
363
+ renderInbox();
364
+ if (config.kind === 'group') {
365
+ // Its lists have no bound, so this page scrolls — see body.group in the CSS.
366
+ document.body.classList.add('group');
367
+ $('pane-group').hidden = false;
368
+ MODERATION.hidden = false; // only a group has any
369
+ renderGroupToggles();
370
+ refreshGroup();
371
+ } else {
372
+ // A person has a follow-request queue too now: nothing binds an inbound
373
+ // Follow to the actor it names, so one that cannot be verified waits here
374
+ // rather than being accepted on the strength of who it claims to be.
375
+ refreshRequests();
376
+ }
377
+ }
378
+
@@ -184,6 +184,7 @@ button:disabled { opacity: .5; cursor: default; }
184
184
  </main>
185
185
 
186
186
  <script src="../bar.js"></script>
187
+ <script src="../common.js"></script>
187
188
  <script src="setup.js"></script>
188
189
  </body>
189
190
  </html>
@@ -3,26 +3,15 @@
3
3
  // at localhost:<port> or at <handle>.localhost:<port>.
4
4
  //
5
5
  // External file, not an inline script: the CSP allows 'self' plus the hashes
6
- // of Phanpy's own inline bootstrap, and nothing else.
6
+ // of Phanpy's own inline bootstrap, and nothing else. `$`, `BASE`, `api` and
7
+ // `postJson` come from ../common.js, loaded first.
7
8
 
8
- const $ = (id) => document.getElementById(id);
9
9
  // Empty means gone, not a blank line where a sentence used to be.
10
10
  const strap = (t) => { const el = $('strap'); el.textContent = t || ''; el.hidden = !t; };
11
11
  // By id, not by position: these were `body > section` until a wrapper went
12
12
  // round them for layout, and the selector then matched nothing — every pane
13
13
  // kept whatever it started as and the page sat on "Reading this agent's state".
14
14
  const show = (id) => { for (const s of document.querySelectorAll('section[id^="pane-"]')) s.hidden = s.id !== id; };
15
- // The door this page is served behind: nothing when the agent runs on this
16
- // machine, `/app` when the identity is hosted by its own pod server. Read from
17
- // the page's own address, so one build serves both.
18
- const BASE = location.pathname.replace(/\/admin\/.*$/u, '');
19
- const api = async (path, init) => {
20
- const res = await fetch(BASE + path, { ...init, headers: { ...(init?.headers || {}), 'x-fedipod-page': '1' } });
21
- return { status: res.status, json: await res.json().catch(() => null) };
22
- };
23
- const postJson = (path, body) => api(path, {
24
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
25
- });
26
15
 
27
16
  let state = null;
28
17
 
@@ -0,0 +1,170 @@
1
+ // upkeep.js — upkeep and the lifecycle: the output pane, drain, recover,
2
+ // log and dead letters; the confirmations for rotate, retire and move; and
3
+ // the inbox backlog panel.
4
+
5
+ // Which button filled the output pane, so clicking that one again closes it
6
+ // rather than re-fetching the same thing under an already-open panel.
7
+ let outputSource = null;
8
+
9
+ const OUTPUT_TITLES = { log: 'Log', deadletter: 'Dead letters', drain: 'Inbox drain', rebuild: 'Recovered posts' };
10
+ const output = (obj, source = null) => {
11
+ closePanels('output');
12
+ outputSource = source;
13
+ solWindow.show('output', OUTPUT_TITLES[source] || 'Output');
14
+ $('output').textContent = typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2);
15
+ };
16
+
17
+ const showingFrom = (source) => outputSource === source && solWindow.openId() === 'output';
18
+
19
+ // Drain is the one that does work rather than reveals it, so a second click
20
+ // re-runs instead of closing. It can take a while — the agent joins a sweep
21
+ // already in flight and queues another after it — and a button that looks dead
22
+ // for a minute reads as broken.
23
+ $('do-drain').addEventListener('click', async (ev) => {
24
+ const b = ev.currentTarget;
25
+ b.disabled = true;
26
+ b.textContent = 'draining…';
27
+ say('draining the inbox — if a sweep is already running this waits for it to finish');
28
+ const r = await write('/drain', {}, 'inbox drained');
29
+ b.disabled = false;
30
+ b.textContent = 'Drain the inbox';
31
+ if (r) {
32
+ const box = r.inbox || {};
33
+ say(`inbox drained — ${box.count ?? 0} still waiting`);
34
+ output(r, 'drain');
35
+ }
36
+ });
37
+
38
+ // Like the drain, this does work rather than reveals it, so a second click
39
+ // re-runs. Nothing here can lose anything: it only adds posts back.
40
+ $('do-rebuild').addEventListener('click', async (ev) => {
41
+ const b = ev.currentTarget;
42
+ b.disabled = true;
43
+ b.textContent = 'recovering…';
44
+ say('reading what the pod still holds — one request per post, so this takes a moment');
45
+ const r = await write('/rebuild', {}, 'checked');
46
+ b.disabled = false;
47
+ b.textContent = 'Recover posts';
48
+ if (!r) return;
49
+ if (r.why) { say(r.why, 'err'); return; }
50
+ if (!r.landed) { say('recovered posts could NOT be saved — see the log', 'err'); return; }
51
+ say(r.recovered
52
+ ? `recovered ${r.recovered} post(s) the pod had and this machine did not`
53
+ : `nothing was missing — the pod indexed ${r.indexed} post(s), all of them already here`);
54
+ output(r, 'rebuild');
55
+ });
56
+
57
+ $('do-log').addEventListener('click', async () => {
58
+ if (showingFrom('log')) { closePanels(); return; }
59
+ const { json } = await api('/log');
60
+ output((json?.lines || []).slice(-60).join('\n') || 'nothing logged yet', 'log');
61
+ });
62
+
63
+ $('do-deadletter').addEventListener('click', async () => {
64
+ if (showingFrom('deadletter')) { closePanels(); return; }
65
+ const { json } = await api('/deadletter');
66
+ output(json?.items?.length ? json.items : 'no dead letters', 'deadletter');
67
+ });
68
+
69
+ // ---- lifecycle ----
70
+ // None of these can be taken back by clicking again, so the button only opens
71
+ // the matching warning in the markup; the second click is the one that acts.
72
+ // Retire also wants the handle typed, because a misclick cannot produce it.
73
+
74
+ const LIFECYCLE = {
75
+ 'rotate-key': { path: '/rotate-key', title: 'Rotate the signing key', done: (r) => (r.changed ? 'rotated and republished' : 'no change — the key was already fresh') },
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
+ move: { path: '/move', title: 'Transfer this account away', go: 'Transfer it', focus: 'move-target',
78
+ done: (r) => `transferred to ${r.target}: Move delivered to ${r.inboxes} inbox(es), unfollowed ${r.unfollowed}/${r.following}` },
79
+ };
80
+ let pending = null;
81
+
82
+ const closeConfirm = () => closePanels();
83
+
84
+ for (const btn of document.querySelectorAll('[data-confirm]')) {
85
+ btn.addEventListener('click', () => {
86
+ const what = btn.dataset.confirm;
87
+ // The same button again closes its question rather than re-asking it.
88
+ if (pending === what && solWindow.openId() === 'confirm-form') { closePanels(); return; }
89
+ closePanels(); // including whatever else was open
90
+ pending = what;
91
+ const spec = LIFECYCLE[what];
92
+ $(`warn-${what}`).hidden = false;
93
+ solWindow.show('confirm-form', spec.title);
94
+ $('confirm-go').className = spec.danger ? 'danger' : 'primary';
95
+ $('confirm-go').textContent = spec.go || 'Confirm';
96
+ if (spec.focus) $(spec.focus).focus();
97
+ else if (what === 'retire') $('confirm-handle').focus();
98
+ });
99
+ }
100
+
101
+ // Offered from inside the retire warning: someone reading it has already said
102
+ // what they want ("not this account, here, any more") and these are the two
103
+ // answers that are not destruction. Switching panels rather than closing means
104
+ // they do not have to go and find the button themselves.
105
+ const openConfirm = (what) => {
106
+ closePanels(); // or `show` toggles: same panel, different warning
107
+ document.querySelector(`[data-confirm="${what}"]`).click();
108
+ };
109
+ // Park lives on the status control now; from inside the retire warning it is
110
+ // still one click — close the question and park.
111
+ $('go-park').addEventListener('click', () => { closePanels(); setStatus(true); });
112
+ $('go-move').addEventListener('click', () => openConfirm('move'));
113
+
114
+ $('confirm-cancel').addEventListener('click', () => { closeConfirm(); say('nothing changed'); });
115
+
116
+ $('confirm-form').addEventListener('submit', async (ev) => {
117
+ ev.preventDefault();
118
+ if (!pending) return;
119
+ const what = pending;
120
+ const body = what === 'retire' ? { confirm: $('confirm-handle').value.trim() }
121
+ : what === 'move' ? { target: $('move-target').value.trim(), confirm: $('confirm-handle-move').value.trim() }
122
+ // Only where the field is shown (the browser build — see index.html).
123
+ // On the Node agent the row stays hidden and nothing is sent, which is
124
+ // what that agent expects.
125
+ : what === 'rotate-key' && !$('rotate-pw-row').hidden
126
+ ? { password: $('rotate-password').value }
127
+ : {};
128
+ if (what === 'move' && !body.target) { say('name the account to transfer to', 'err'); return; }
129
+ $('confirm-go').disabled = true;
130
+ say(`${what} — this talks to the pod and to other servers, so it takes a moment`);
131
+ const r = await write(LIFECYCLE[what].path, body, what);
132
+ $('confirm-go').disabled = false;
133
+ if (!r) return; // write() already said why
134
+ closeConfirm();
135
+ say(LIFECYCLE[what].done(r));
136
+ load(); // mode, and whether it is retired, both changed
137
+ });
138
+
139
+ // ---- inbox ----
140
+ // Only appears when there is enough waiting to be worth a decision. The agent
141
+ // drains oldest-first regardless; this exists to let the owner say "do not
142
+ // bother with that fortnight", which is not a call an agent should make on
143
+ // someone's mail by itself.
144
+ const INBOX_PROMPT_AT = 500;
145
+ let dismissed = false;
146
+
147
+ async function renderInbox() {
148
+ if (dismissed) return;
149
+ const { json: st } = await api('/status');
150
+ const box = st?.inbox;
151
+ const panel = $('pane-inbox');
152
+ if (!box || box.count < INBOX_PROMPT_AT) { panel.hidden = true; return; }
153
+ const mb = box.bytes >= 1048576
154
+ ? `${(box.bytes / 1048576).toFixed(1)} MB` : `${Math.round(box.bytes / 1024)} kB`;
155
+ const since = box.oldest ? new Date(box.oldest).toLocaleDateString() : 'unknown';
156
+ $('inbox-summary').textContent =
157
+ `${box.count.toLocaleString()} deliveries waiting (${mb}), the oldest from ${since}.`;
158
+ // One request each to read and delete, and the agent holds itself to 60 a
159
+ // minute, so the honest number is minutes not seconds.
160
+ $('inbox-warn').hidden = box.count < 2000;
161
+ $('inbox-warn').textContent = box.count >= 2000
162
+ ? `At 60 requests a minute this is roughly ${Math.ceil(box.count * 2 / 60)} minutes of `
163
+ + 'draining if you keep everything. Discarding the old content is much quicker.'
164
+ : '';
165
+ panel.hidden = false;
166
+ }
167
+
168
+ // The gateway, as a facts row under software: attach through a multi-user
169
+ // front with this agent's own credential, detach back to the pod inbox. The
170
+ // forms live in the floating window, like every other disclosure.
package/web/app/README.md CHANGED
@@ -5,13 +5,13 @@ See `claude/plans/browser-agent.md` for the whole design and status.
5
5
 
6
6
  | file | what |
7
7
  |---|---|
8
- | `pod-auth.mjs` | The pod side of sign-in, browser-native: create a CSS account + pod, mint a client credential, and a DPoP-bound `fetch` that writes to the pod. The twin of `lib/account.mjs` + `vendor/idp-grant.cjs`. |
8
+ | `pod-auth.mjs` | The pod side of sign-in, browser-native: create a CSS account + pod, mint a client credential, and a DPoP-bound `fetch` that writes to the pod. The twin of `lib/device/account.mjs` + `vendor/idp-grant.cjs`. |
9
9
  | `keystore.mjs` | WebCrypto RSA/Ed25519 key generation, and wrapping the keys under the account password (PBKDF2-SHA256 + AES-GCM-256). The pod holds only the wrapped form, so the pod's host cannot sign as you. |
10
10
  | `keys-browser.mjs` | Importing a keys record for signing, and finding one: this browser's opened copy in IndexedDB first, else the pod's. A wrapped one the browser has not opened yet raises `KeyPasswordNeeded`, which `boot.mjs` answers with the unlock pane — once per browser. |
11
11
  | `signup.mjs` | The `fedipod setup` flow, in the browser, up to publish: account, pod, credential, keys locked on the pod (owner-only ACL written *before* the key). Produces the credential/keys/config shapes the agent already reads. |
12
12
  | `shims/fedify-sig.mjs` | Browser stand-in for `@fedify/fedify/sig` (which will not bundle for a browser). `sign()` returns signed headers as data for the relay; `signRequest()` wraps it Fedify-shaped. Proven byte-identical to Fedify. |
13
13
  | `shims/node-crypto.mjs` | Browser stand-in for `node:crypto` — the small synchronous slice the agent uses, via crypto-browserify, plus native WebCrypto. |
14
- | `shims/safefetch.mjs` | Browser stand-in for `lib/safefetch.mjs`. Pinning and the private-address checks are unnecessary here (a browser closes DNS rebinding itself); the BYTE BUDGET is not, so `readCapped` streams and stops at the cap exactly as the Node one does. |
14
+ | `shims/safefetch.mjs` | Browser stand-in for `lib/shared/safefetch.mjs`. Pinning and the private-address checks are unnecessary here (a browser closes DNS rebinding itself); the BYTE BUDGET is not, so `readCapped` streams and stops at the cap exactly as the Node one does. |
15
15
  | `dist/` | The bundled agent, built by `scripts/build-app.mjs`. Committed like `phanpy/dist`, regenerated on release. (Not present until the agent entry is built.) |
16
16
 
17
17
  Build: `node scripts/build-app.mjs`. Tests live in `claude/validation/`:
@@ -39,12 +39,12 @@ worker answers the data endpoints it calls. See
39
39
 
40
40
  | file | what |
41
41
  |---|---|
42
- | `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/admin.mjs`. Personal only. |
43
- | `atproto-browser.mjs` | The Bluesky connection (`lib/atproto.mjs`) with a per-connection storage choice — IndexedDB (this browser) or owner-only pod state — plus pause. |
44
- | `fediacct-browser.mjs` | Connections to fediverse accounts on other servers (`lib/fediacct.mjs`): the OAuth dance, the same storage choice, and a best-effort server-side revoke on disconnect. |
42
+ | `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. |
43
+ | `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. |
44
+ | `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. |
45
45
  | `idb-kv.mjs` | The per-origin IndexedDB key/value store the two connectors use for "on this device, never the pod" credentials. |
46
46
 
47
- `agent.mjs` also holds the single-active-agent **lease** (`lib/lease.mjs`) — a
47
+ `agent.mjs` also holds the single-active-agent **lease** (`lib/core/lease.mjs`) — a
48
48
  second device runs read-only until it takes over — and starts the drain/mirrors
49
49
  only when it is the active holder.
50
50
 
@@ -14,10 +14,10 @@
14
14
  //
15
15
  // It mirrors lib/admin.mjs's handlers for those paths, calling the same agent
16
16
  // objects (store, publisher, intake, deliverer, remote) the Node agent does.
17
- import { publicHandle, webfingerHost } from '../../lib/wire.mjs';
17
+ import { publicHandle, webfingerHost } from '../../lib/core/wire.mjs';
18
18
  import * as podInbox from '../../lib/pod/inbox.mjs';
19
- import { normalizeImport, IMPORT_KINDS } from '../../lib/import.mjs';
20
- import { hashPassword } from '../../lib/mastoapi.mjs';
19
+ import { normalizeImport, IMPORT_KINDS } from '../../lib/connections/import.mjs';
20
+ import { hashPassword } from '../../lib/client/masto/index.mjs';
21
21
 
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'];