fedipod 1.50.2 → 1.55.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +2 -1
  2. package/gateway.md +25 -0
  3. package/gui.md +11 -3
  4. package/lib/client/masto/timelines.mjs +5 -0
  5. package/lib/connections/acctfeed.mjs +4 -1
  6. package/lib/connections/tagfeed.mjs +4 -1
  7. package/lib/core/intake/index.mjs +35 -2
  8. package/lib/core/intake/verify.mjs +3 -1
  9. package/lib/core/pod-only.mjs +4 -2
  10. package/lib/core/publisher/index.mjs +19 -5
  11. package/lib/gateway/account-agent.mjs +11 -5
  12. package/lib/gateway/check-letter.mjs +123 -0
  13. package/lib/gateway/connected-feeds.mjs +116 -0
  14. package/lib/gateway/copy.mjs +19 -0
  15. package/lib/gateway/front-core.mjs +5 -3
  16. package/lib/gateway/held-mail.mjs +25 -1
  17. package/lib/gateway/keeper.mjs +2 -1
  18. package/lib/gateway/masto-gateway.mjs +47 -7
  19. package/lib/surface/routes/owner.mjs +7 -4
  20. package/package.json +3 -3
  21. package/scripts/stage-site.mjs +2 -3
  22. package/vendor/sanitize-html.cjs +358 -171
  23. package/web/admin/bar.js +45 -0
  24. package/web/admin/group.js +17 -0
  25. package/web/admin/index.html +12 -8
  26. package/web/admin/record.js +19 -41
  27. package/web/admin/upkeep.js +3 -0
  28. package/web/app/README.md +3 -3
  29. package/web/app/admin-facade.mjs +19 -16
  30. package/web/app/agent.mjs +65 -2
  31. package/web/app/atproto-browser.mjs +28 -22
  32. package/web/app/deliver-relay.mjs +2 -1
  33. package/web/app/dist/boot.js +1 -0
  34. package/web/app/dist/boot.js.map +2 -2
  35. package/web/app/dist/sw.js +567 -265
  36. package/web/app/dist/sw.js.map +4 -4
  37. package/web/app/fediacct-browser.mjs +32 -30
  38. package/web/app/idb-kv.mjs +4 -4
  39. package/web/app/shims/prelude.js +1 -0
  40. package/web/app/sw-src.mjs +44 -3
@@ -2,33 +2,31 @@
2
2
  // OTHER servers, in the browser.
3
3
  //
4
4
  // Same FediAccounts client the Node agent uses (lib/fediacct.mjs): the same
5
- // OAuth dance and authenticated calls. What the browser adds is the owner's
6
- // per-connection storage choice, the same one Bluesky has:
7
- //
8
- // - "browser": the credential lives in IndexedDB on this device (default).
9
- // - "pod": it lives in the owner-only pod state, so it follows the owner to
10
- // any browser they sign in from.
11
- //
12
- // A record carries which backend it lives in; list() merges both, and moving a
13
- // connection is a re-write to the other backend. App registrations and the
14
- // short-lived OAuth "pending" state stay in IndexedDB (per browser), and the
15
- // pending state is persisted so a connection survives the worker being
16
- // idle-killed between the redirect out and the code coming back.
5
+ // OAuth dance and authenticated calls. The credentials live in the owner-only
6
+ // pod state, on the pod only (lib/core/pod-only.mjs), so they follow the owner
7
+ // to any browser they sign in from. Records an earlier build kept in this
8
+ // browser's IndexedDB are still read, and moveToPod() takes them to the pod.
9
+ // App registrations and the short-lived OAuth "pending" state stay in
10
+ // IndexedDB (per browser), and the pending state is persisted so a connection
11
+ // survives the worker being idle-killed between the redirect out and the code
12
+ // coming back.
17
13
  import { FediAccounts } from '../../lib/connections/fediacct.mjs';
18
14
  import { kvAll, kvPut, kvDel } from './idb-kv.mjs';
19
15
 
20
- const POD_DOC = 'fediaccts.json'; // { id: record } for pod-stored connections
16
+ const POD_DOC = 'fediaccts.json'; // { id: record }
21
17
  const PEND_TTL_MS = 10 * 60_000;
22
18
 
23
19
  export class BrowserFediAccounts extends FediAccounts {
24
20
  constructor({ store, actorId = null, log = console.log } = {}) {
25
21
  super({ localDir: '/fediaccts', actorId, log }); // localDir unused; storage below
26
22
  this.store = store;
27
- this._recs = new Map(); // browser (IndexedDB) records, id → record
23
+ this._recs = new Map(); // records still in this browser (IndexedDB), id → record
28
24
  this._apps = new Map(); // host → app registration
29
25
  }
30
26
 
31
- async load() {
27
+ // `fromPod`: the pod's records read as well (agent.mjs says when).
28
+ async load({ fromPod = false } = {}) {
29
+ if (fromPod) await this.store.refresh?.([POD_DOC], { ifChanged: true }).catch((e) => this.log(`connected accounts: ${e.message}`));
32
30
  const all = await kvAll().catch(() => ({}));
33
31
  for (const [k, v] of Object.entries(all)) {
34
32
  if (k.startsWith('rec:')) this._recs.set(k.slice(4), v);
@@ -47,8 +45,8 @@ export class BrowserFediAccounts extends FediAccounts {
47
45
 
48
46
  read(id) {
49
47
  let rec = null; let where = null;
50
- if (this._recs.has(id)) { rec = this._recs.get(id); where = 'browser'; }
51
- else { const p = this._podMap()[id]; if (p) { rec = p; where = 'pod'; } }
48
+ const p = this._podMap()[id];
49
+ if (p) { rec = p; where = 'pod'; } else if (this._recs.has(id)) { rec = this._recs.get(id); where = 'browser'; }
52
50
  if (!rec) return null;
53
51
  if (rec.mintedFor && this.actorId && rec.mintedFor !== this.actorId) {
54
52
  this.log(`fediaccts/${id} belongs to ${rec.mintedFor} — not reusing it for ${this.actorId}`);
@@ -58,17 +56,23 @@ export class BrowserFediAccounts extends FediAccounts {
58
56
  }
59
57
 
60
58
  write(rec) {
61
- const where = rec.storage || 'browser';
62
- const clean = { ...rec, storage: where };
63
- if (where === 'pod') {
64
- const map = { ...this._podMap(), [clean.id]: clean };
65
- this.store.write(POD_DOC, map); this.store.flush?.().catch(() => {});
66
- this._recs.delete(clean.id); kvDel('rec:' + clean.id).catch(() => {});
67
- } else {
68
- this._recs.set(clean.id, clean); kvPut('rec:' + clean.id, clean).catch((e) => this.log(`fediacct persist: ${e.message}`));
69
- const map = this._podMap();
70
- if (clean.id in map) { delete map[clean.id]; this.store.write(POD_DOC, map); this.store.flush?.().catch(() => {}); }
71
- }
59
+ const clean = { ...rec, storage: 'pod' };
60
+ const map = { ...this._podMap(), [clean.id]: clean };
61
+ this.store.write(POD_DOC, map); this.store.flush?.().catch(() => {});
62
+ }
63
+
64
+ // The records still in this browser, onto the pod, and out of the browser
65
+ // once the pod has them. One the pod already holds is newer there.
66
+ async moveToPod() {
67
+ const moving = [...this._recs.values()].filter((r) => !r.mintedFor || !this.actorId || r.mintedFor === this.actorId);
68
+ if (!moving.length) return 0;
69
+ const map = { ...this._podMap() };
70
+ for (const r of moving) if (!(r.id in map)) map[r.id] = { ...r, storage: 'pod' };
71
+ this.store.write(POD_DOC, map);
72
+ if (!await this.store.commit()) return 0;
73
+ for (const r of moving) { this._recs.delete(r.id); await kvDel('rec:' + r.id).catch(() => {}); }
74
+ this.log(`${moving.length} connected account(s) moved from this browser to the pod`);
75
+ return moving.length;
72
76
  }
73
77
 
74
78
  // Best-effort: tell the server to revoke our token (RFC 7009), so a
@@ -109,8 +113,6 @@ export class BrowserFediAccounts extends FediAccounts {
109
113
  }));
110
114
  }
111
115
 
112
- // Move a connection between backends (the Options menu's Storage control).
113
- setStorage(id, where) { const rec = this.read(id); if (rec && (where === 'pod' || where === 'browser')) { this.write({ ...rec, storage: where }); return this.roster().find(r => r.id === id) || null; } return null; }
114
116
 
115
117
  // One app registration per host, remembered — over IndexedDB.
116
118
  async appFor(host, redirectUri) {
@@ -1,7 +1,7 @@
1
- // idb-kv.mjs — a tiny per-origin key/value store on IndexedDB, for the browser
2
- // agent's "on this device, never the pod" storage: a connected account's
3
- // credential when the owner chooses to keep it in this browser rather than let
4
- // it follow them to their pod. Shared by atproto-browser and fediacct-browser.
1
+ // idb-kv.mjs — a tiny per-origin key/value store on IndexedDB, for what the
2
+ // browser agent keeps on this device: app registrations, the short OAuth
3
+ // pending state, and connected-account credentials an earlier build kept here
4
+ // until they move to the pod. Shared by atproto-browser and fediacct-browser.
5
5
  const DB = 'fedipod-accounts';
6
6
 
7
7
  function open() {
@@ -54,5 +54,6 @@ globalThis.Buffer ??= (() => {
54
54
  concat: (list) => { const n = list.reduce((s, x) => s + x.length, 0); const o = new Buf(n); let i = 0; for (const x of list) { o.set(x, i); i += x.length; } return o; },
55
55
  alloc: (n) => new Buf(n),
56
56
  isBuffer: (x) => x instanceof Uint8Array,
57
+ byteLength: (d, enc) => (typeof d === 'string' ? fromString(d, enc).length : d.byteLength ?? d.length),
57
58
  };
58
59
  })();
@@ -10,10 +10,28 @@ import { BrowserAgent } from './agent.mjs';
10
10
  import { getSession } from './oidc-session.mjs';
11
11
  import { ADMIN_PATHS } from './admin-facade.mjs';
12
12
  import { bridge } from '../../lib/client/masto/bridge.mjs';
13
+ import { kvGet, kvPut } from './idb-kv.mjs';
13
14
 
14
15
  let agent = null;
15
16
  let booting = null;
16
17
 
18
+ // What the agent has said lately, for the manage page's Show the log: the last
19
+ // 300 lines, kept in this browser across the worker's restarts, each with this
20
+ // browser's own date and time.
21
+ const LOG_KEEP = 300;
22
+ let logLines = [];
23
+ let logTimer = null;
24
+ const logRead = kvGet('log').then((kept) => { logLines = [...(Array.isArray(kept) ? kept : []), ...logLines].slice(-LOG_KEEP); }).catch(() => {});
25
+ function agentLog(m) {
26
+ console.log('[sw-agent]', m);
27
+ const at = new Date().toLocaleString(undefined, { month: '2-digit', day: '2-digit', hour: 'numeric', minute: '2-digit', second: '2-digit' });
28
+ logLines.push(`${at} ${m}`);
29
+ if (logLines.length > LOG_KEEP) logLines.splice(0, logLines.length - LOG_KEEP);
30
+ clearTimeout(logTimer);
31
+ logTimer = setTimeout(() => { logRead.then(() => kvPut('log', logLines)).catch(() => {}); }, 1500);
32
+ }
33
+ const recentLog = async () => { await logRead; return logLines; };
34
+
17
35
  const FACADE = /^\/(api\/v[12]\/|oauth\/|nodeinfo\/)/; // Mastodon paths only — never the front's own /api/handle, /api/attach, /api/relay
18
36
  const FACADE_EXACT = new Set(['/.well-known/nodeinfo', '/.well-known/oauth-authorization-server', '/nodeinfo/2.0']);
19
37
  const isFacade = (p) => FACADE.test(p) || FACADE_EXACT.has(p);
@@ -109,7 +127,8 @@ self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
109
127
  async function bootFromSession(frontOrigin, restart) {
110
128
  const oidc = await getSession();
111
129
  if (!oidc) throw new Error('no session — sign in first');
112
- const a = new BrowserAgent({ log: (m) => console.log('[sw-agent]', m) });
130
+ const a = new BrowserAgent({ log: agentLog });
131
+ a.recentLog = recentLog;
113
132
  await a.boot({ oidc, frontOrigin: frontOrigin || self.location.origin, restart });
114
133
  agent = a;
115
134
  return a;
@@ -135,7 +154,9 @@ self.addEventListener('message', (e) => {
135
154
  return;
136
155
  }
137
156
  if (e.data?.type !== 'boot') return;
138
- ensureBooting(e.data.frontOrigin)
157
+ const booted = ensureBooting(e.data.frontOrigin);
158
+ e.waitUntil(stillWorking(booted));
159
+ booted
139
160
  .then(() => e.source?.postMessage({ type: 'booted' }))
140
161
  // `code` matters: a boot that failed only because this browser has never
141
162
  // opened the signing key is not a failure the page should show as one — it
@@ -151,9 +172,29 @@ self.addEventListener('fetch', (e) => {
151
172
  // sending its owner back to that sign-in: the gateway's, not this browser's
152
173
  // agent, whose own client signs in from a page on this origin.
153
174
  if (url.pathname.startsWith('/oauth/') && !sameOriginReferrer(e.request)) return;
154
- e.respondWith(serve(e.request, url));
175
+ const served = serve(e.request, url);
176
+ e.respondWith(served);
177
+ e.waitUntil(stillWorking(served));
155
178
  });
156
179
 
180
+ // A browser stops a worker about thirty seconds after its last event, however
181
+ // far the agent's own work has got. Each request keeps it up until the agent's
182
+ // start and its inbox rounds are done, for at most four minutes (a browser
183
+ // allows one event five); the next request carries it on.
184
+ const STILL_WORKING_MS = 4 * 60_000;
185
+ function stillWorking(first) {
186
+ const work = (async () => {
187
+ await Promise.resolve(first).catch(() => {});
188
+ const a = agent;
189
+ if (!a) return;
190
+ await a.provisioning?.catch(() => {});
191
+ await a.intake?.settled?.();
192
+ })().catch(() => {});
193
+ let timer;
194
+ const cap = new Promise((r) => { timer = setTimeout(r, STILL_WORKING_MS); });
195
+ return Promise.race([work, cap]).finally(() => clearTimeout(timer));
196
+ }
197
+
157
198
  async function serve(request, url) {
158
199
  // Before anything is booted or read: is this caller allowed to be here at all?
159
200
  const refuse = notAllowed(request, url);