fedipod 1.39.0 → 1.40.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 (55) hide show
  1. package/README.md +15 -7
  2. package/architecture.md +8 -0
  3. package/gateway.md +68 -7
  4. package/lib/client/masto/bridge.mjs +61 -0
  5. package/lib/client/masto/index.mjs +4 -1
  6. package/lib/client/masto/oauth.mjs +1 -1
  7. package/lib/client/masto/statuses.mjs +3 -0
  8. package/lib/connections/acctfeed.mjs +13 -9
  9. package/lib/connections/bskyfeed.mjs +13 -9
  10. package/lib/connections/tagfeed.mjs +14 -9
  11. package/lib/core/intake/index.mjs +193 -46
  12. package/lib/core/intake/verify.mjs +11 -0
  13. package/lib/core/lease.mjs +15 -1
  14. package/lib/core/pod-only.mjs +4 -0
  15. package/lib/core/publisher/index.mjs +2 -0
  16. package/lib/core/scheduled.mjs +39 -0
  17. package/lib/core/storage.mjs +67 -0
  18. package/lib/core/store.mjs +66 -4
  19. package/lib/gateway/account-agent.mjs +111 -0
  20. package/lib/gateway/copy.mjs +381 -0
  21. package/lib/gateway/front-core.mjs +24 -9
  22. package/lib/gateway/gateway-core.mjs +20 -1
  23. package/lib/gateway/held-mail.mjs +198 -0
  24. package/lib/gateway/keeper-due.mjs +39 -0
  25. package/lib/gateway/keeper-session.mjs +10 -0
  26. package/lib/gateway/keeper.mjs +72 -0
  27. package/lib/gateway/masto-gateway.mjs +510 -0
  28. package/lib/gateway/quiet.mjs +7 -2
  29. package/lib/gateway/relay-extras.mjs +6 -6
  30. package/lib/gateway/state-api.mjs +207 -0
  31. package/lib/pod/containers.mjs +17 -0
  32. package/lib/pod/transport.mjs +43 -2
  33. package/lib/session/README.md +5 -5
  34. package/lib/session/demo.html +1 -1
  35. package/lib/session/package.json +1 -1
  36. package/package.json +2 -2
  37. package/run-agent.mjs +4 -16
  38. package/scripts/stage-site.mjs +17 -4
  39. package/web/admin/gateway.js +14 -1
  40. package/web/admin/index.html +4 -0
  41. package/web/admin/oauth-signin.mjs +1 -1
  42. package/web/app/README.md +1 -1
  43. package/web/app/admin-facade.mjs +11 -1
  44. package/web/app/agent.mjs +220 -52
  45. package/web/app/boot.mjs +3 -0
  46. package/web/app/copy-mode.mjs +223 -0
  47. package/web/app/dist/boot.js +68 -2
  48. package/web/app/dist/boot.js.map +4 -4
  49. package/web/app/dist/sw.js +1033 -202
  50. package/web/app/dist/sw.js.map +4 -4
  51. package/web/app/sw-src.mjs +15 -53
  52. package/web/app/update.js +3 -2
  53. package/web/app/warm-start.mjs +115 -0
  54. package/web/app-signin/app-signin.mjs +78 -0
  55. package/web/app-signin/index.html +41 -0
package/README.md CHANGED
@@ -4,7 +4,9 @@
4
4
 
5
5
  FediPod gives you a Fediverse account whose data lives on a Solid pod. You
6
6
  follow people on Mastodon, Bluesky, and other Fediverse or ATProto servers in
7
- one timeline. Your posts, followers and settings stay on your pod.
7
+ one timeline. Your posts, followers and settings are kept on your pod. While fedipod.net
8
+ keeps your account running, it works from a copy of them and writes it to your
9
+ pod every fifteen minutes; your signing key never leaves your pod.
8
10
 
9
11
  The easiest way to run FediPod is to use it in any browser at https://fedipod.net. Nothing to install. Sign-up points you to a pod provider if you need a pod, then attaches a Fediverse identity to the pod you sign in with.
10
12
 
@@ -92,14 +94,16 @@ there it takes over; the first drops back to reading.
92
94
 
93
95
  ## What the browser version does not do
94
96
 
95
- - **Scheduled posts.** Nothing runs between now and the time you picked, so
97
+ - **Scheduled posts, if you stop fedipod.net keeping your account running.**
98
+ It does by default, and publishes them while FediPod is closed. Turned off
99
+ on the manage page, nothing runs between now and the time you picked, so
96
100
  the composer refuses one rather than dropping it later.
97
- - **Notifications while the client is closed.** There is no push service.
98
- Open the tab and they are there.
101
+ - **Notifications, and other clients, if you stop fedipod.net keeping your
102
+ account running.** While it does (the default), any Mastodon app, phone or
103
+ desktop, can use your account with `fedipod.net` as its server, and an app
104
+ that offers notifications gets them on your phone with everything closed.
99
105
  - **Live updates.** The client refreshes by polling.
100
106
  - **Hosting a group.** Joining one works.
101
- - **Other clients.** A phone app or desktop client has nothing on the network
102
- to connect to.
103
107
 
104
108
  For these, see [Other ways to run FediPod](#other-ways-to-run-fedipod).
105
109
 
@@ -108,6 +112,10 @@ For these, see [Other ways to run FediPod](#other-ways-to-run-fedipod).
108
112
  Everything you publish and everything you read is stored on your pod. Your
109
113
  signing key is stored there too, in a container only you can read through
110
114
  your pod's login.
115
+ While fedipod.net keeps your account running, its own pod identity can read
116
+ that container too, so it can act for you while FediPod is closed, and it
117
+ works from a copy of your account's data that it writes back to your pod every
118
+ fifteen minutes. Your key stays on your pod only.
111
119
  It all lives in the `fedipod` container you chose at sign-up. Your pod's
112
120
  public type index records it, as an ActivityStreams actor, which is how
113
121
  FediPod and other Solid apps find your account again.
@@ -119,7 +127,7 @@ not change.
119
127
 
120
128
  ## Other ways to use FediPod
121
129
 
122
- - [fediverse-account]() - an ESM library that supports both Solid-based and regular Fediverse accounts with methods to login, reply, boost, etc.
130
+ - [fediverse-session](lib/session/README.md) - an ESM library that supports both Solid-based and regular Fediverse accounts with methods to login, reply, boost, etc.
123
131
  - [FediPod Server](packages/fedipod-server/README.md): a full ActivityPub
124
132
  server as a Community Solid Server component, giving every pod on the server
125
133
  the option of a Fediverse account.
package/architecture.md CHANGED
@@ -16,6 +16,14 @@ and forwards the rest to the pod inbox with a receipt. It holds no key. The
16
16
  browser version always has one; the DeviceAgent may use one. Any
17
17
  lightweight host will do, Netlify included.
18
18
 
19
+ A gateway can also keep a browser account running. It then acts as that
20
+ account's agent whenever FediPod is closed, under a pod identity of its own
21
+ that the owner's rules name, reading the signing key from the pod when it
22
+ needs it. The account's state documents live in a working copy at the
23
+ gateway, which FediPod in the browser, the gateway and any Mastodon app all
24
+ work from, and which the gateway writes to the pod every fifteen minutes. See
25
+ [the gateway](gateway.md).
26
+
19
27
  [FediPod Server](packages/fedipod-server/README.md) puts the agent inside a
20
28
  Community Solid Server, so anyone with a pod on that server can opt in to a
21
29
  Fediverse account fed by the server itself.
package/gateway.md CHANGED
@@ -6,13 +6,16 @@ each delivery's signature where the headers still exist, drops forgeries and
6
6
  junk before they ever touch your pod, and passes the rest on with a receipt
7
7
  saying it checked.
8
8
 
9
- Your name, your signing key and your data stay on your own pod. The gateway is
9
+ Your name, your signing key and your data are kept on your own pod. The gateway is
10
10
  **keyless** — it never holds the key you sign with, so it cannot post as you,
11
11
  read your private things, or be you anywhere. The worst a broken one can do is
12
12
  push items into your inbox, and those still face your agent's own checks.
13
13
  With the outbox door below, a gateway can also hand your agent a post marked as
14
14
  yours, which your agent then signs and sends. It still holds no key, but what you
15
- trust it with grows by that much.
15
+ trust it with grows by that much. A browser account is also kept running by
16
+ the gateway (below). That gives the gateway your key while it works, so it can
17
+ post as you, as any Fediverse server can, and your account's working data is
18
+ kept at the gateway too and written to your pod every fifteen minutes.
16
19
 
17
20
  A FediPod install works without any gateway at all. Deliveries go straight to
18
21
  your pod inbox, which holds them whether your agent is running or not.
@@ -153,10 +156,61 @@ on the setup page). Setup reads the account on the pod, keeps its state and
153
156
  key there, attaches at the new gateway, and completes the move when the
154
157
  agent first acts. See [the DeviceAgent](device-agent.md).
155
158
 
159
+ ## While your app is closed
160
+
161
+ A browser account runs only while FediPod is open somewhere. While it is
162
+ closed, fedipod.net does two things for it.
163
+
164
+ **Your mail waits at the gateway.** Deliveries are kept there, not written to
165
+ your pod one by one, and reach your pod in batches of up to a hundred: when you
166
+ open FediPod, or every fifteen minutes otherwise. While FediPod is open, mail
167
+ goes straight to your pod as before. A DeviceAgent's mail always goes straight
168
+ to its pod.
169
+
170
+ **The gateway keeps your account running.** Unless you turn it off, the
171
+ gateway accepts your follows, tries again what failed to go out, and publishes
172
+ the posts you scheduled, all while FediPod is closed. Other mail waits on your
173
+ pod for FediPod to open, as before. It works under its own pod identity, which your app
174
+ names in the access rules on your FediPod folder. It reads your signing key
175
+ from your pod when it needs it, so it can post as you, and keeps no copy of the
176
+ key. **Stop keeping it running** on the manage page takes it out of the rules.
177
+ If fedipod.net ever changes its own pod identity, your FediPod moves your
178
+ account over the next time you open it, with nothing for you to do.
179
+ Scheduling a post is offered only while this is on, because otherwise nothing
180
+ would be running when the time came.
181
+
182
+ **Your account's working data is kept at the gateway.** While the gateway
183
+ keeps your account running, your timeline, notifications, followers, settings
184
+ and the rest of your FediPod state are kept at fedipod.net, and FediPod in your
185
+ browser, the gateway and any app you use all work from that one copy, so they
186
+ never disagree. fedipod.net writes what changed to your pod every fifteen
187
+ minutes, and everything at once when you stop it keeping your account running,
188
+ move your address or close it. Your signing key and the passwords of accounts
189
+ you connected elsewhere are never in the copy; they stay on your pod only.
190
+
191
+ ## Using any Mastodon app
192
+
193
+ While the gateway keeps your account running, any Mastodon app can use it:
194
+ elk.zone, Ivory, Tusky, Phanpy and the rest. In the app, give `fedipod.net` as
195
+ your server. The app sends you to a fedipod.net page that asks for your
196
+ address here and signs you in at your own pod; no password is typed on
197
+ fedipod.net. The app then reads your timeline and notifications from your
198
+ account's copy at the gateway, and posts, boosts, likes and follows as you.
199
+
200
+ An app checks for new posts every minute or so while it is open; there are no
201
+ live updates. Accounts are made on the fedipod.net front page, with a pod, not
202
+ from an app.
203
+
204
+ **Notifications on your phone.** An app that offers notifications can sign up
205
+ for them here. A mention, a reply, a direct message, a follow, or a like, boost
206
+ or reaction to your own post then reaches your phone as it arrives, while
207
+ FediPod and the app are closed. While FediPod is open in a browser, it reads
208
+ your mail itself, and those arrivals are not pushed.
209
+
156
210
  ## Accounts that go quiet
157
211
 
158
- A gateway holds no mail. Every delivery it accepts is written into your pod
159
- inbox, and your agent reads it from there. A BrowserAgent reads only while
212
+ Every delivery the gateway accepts ends up in your pod inbox, and your agent
213
+ reads it from there. A BrowserAgent reads only while
160
214
  its page is open, so an account nobody opens grows on its pod without limit
161
215
  and comes back to a drain of everything at once. fedipod.net keeps two facts
162
216
  about each browser account — when its owner last signed in or posted, and
@@ -194,9 +248,16 @@ web address becomes a link.
194
248
 
195
249
  It reads only public data to decide what concerns you: your published
196
250
  followers and following, and a small public policy document your agent writes
197
- with a mirror of your blocklist. Nothing private leaves your pod. Publishing
198
- that mirror does make your blocklist public, which is part of the bargain of
199
- running behind a door.
251
+ with a mirror of your blocklist. Publishing that mirror does make your
252
+ blocklist public, which is part of the bargain of running behind a door.
253
+
254
+ Three things go further, all for browser accounts only. Mail that waits at
255
+ the gateway while your app is closed, direct messages included, is stored there
256
+ until it reaches your pod. A gateway keeping your account running can read and
257
+ write everything in your FediPod folder, your signing key included, while it
258
+ works; nothing else on your pod is open to it. And it keeps your account's
259
+ working data — your timeline, notifications and direct messages among it — for
260
+ as long as it keeps your account running.
200
261
 
201
262
  ## Running a gateway
202
263
 
@@ -0,0 +1,61 @@
1
+ // bridge.mjs — a fetch Request handed to code written for Node's request and
2
+ // response: the Mastodon facade and the admin surface. The browser worker
3
+ // answers its own client through it (web/app/sw-src.mjs), and the gateway
4
+ // answers Mastodon apps through it (lib/gateway/masto-gateway.mjs).
5
+ //
6
+ // Returns { req, res, bodyBytes, bodyText, response() }: `response()` is what
7
+ // the handler wrote, as a fetch Response.
8
+ export async function bridge(request, url, { sameOrigin = false } = {}) {
9
+ // Bytes, not text. A multipart upload is binary — read as text it comes back
10
+ // through a UTF-8 round trip that replaces every byte that is not valid UTF-8,
11
+ // which is most of a JPEG, so the boundary search found nothing and every
12
+ // media and avatar upload answered "422 file required". readBody() does
13
+ // `data += chunk`, which decodes a Buffer the same way it always did, so the
14
+ // JSON and form paths are unchanged.
15
+ const bodyBytes = (request.method === 'GET' || request.method === 'HEAD')
16
+ ? null : Buffer.from(new Uint8Array(await request.arrayBuffer()));
17
+ const bodyText = bodyBytes ? new TextDecoder().decode(bodyBytes) : '';
18
+ const reqHeaders = {}; for (const [k, v] of request.headers) reqHeaders[k.toLowerCase()] = v;
19
+ // `host` is a forbidden header name, so a fetch Request never carries one and
20
+ // the loop above cannot produce it — but it is a header every real request
21
+ // arrives with, and the agent reads it to say where it lives. Without it the
22
+ // notifications `Link` header named `https://undefined/`, so a client that
23
+ // paged by following it (which is how a client is meant to page) walked off
24
+ // the origin and saw nothing past the first screen.
25
+ reqHeaders.host = url.host;
26
+ const listeners = {};
27
+ // The body's events fire on the next microtask. A route that reads the body
28
+ // may only register for them after an await or two — the facade dispatches
29
+ // through its area modules first — so a listener that arrives after the
30
+ // events have fired is given them at once, in the order it asks.
31
+ let fired = false;
32
+ const req = { method: request.method, url: url.pathname + url.search, headers: reqHeaders,
33
+ // Whether the caller is a page on the agent's own origin. The browser
34
+ // worker has established that before anything reaches the facade, and
35
+ // MastoApi asks (through its authorities) whether a request is the owner's
36
+ // own; a request at the gateway never is.
37
+ sameOrigin,
38
+ socket: { encrypted: url.protocol === 'https:' },
39
+ on(ev, cb) {
40
+ (listeners[ev] ||= []).push(cb);
41
+ if (fired) { if (ev === 'data' && bodyBytes?.length) cb(bodyBytes); else if (ev === 'end') cb(); }
42
+ return req;
43
+ },
44
+ destroy() {} };
45
+ queueMicrotask(() => {
46
+ fired = true;
47
+ if (bodyBytes?.length) (listeners.data || []).forEach((cb) => cb(bodyBytes));
48
+ (listeners.end || []).forEach((cb) => cb());
49
+ });
50
+ let status = 200; const outHeaders = {}; const chunks = [];
51
+ const res = {
52
+ writeHead(s, h) { status = s; if (h) Object.assign(outHeaders, h); return res; },
53
+ setHeader(k, v) { outHeaders[k] = v; }, getHeader(k) { return outHeaders[k]; },
54
+ write(c) { chunks.push(c); }, end(c) { if (c) chunks.push(c); },
55
+ };
56
+ return {
57
+ req, res, bodyBytes, bodyText,
58
+ get status() { return status; },
59
+ response: () => new Response(chunks.join(''), { status, headers: { 'content-type': 'application/json', ...outHeaders } }),
60
+ };
61
+ }
@@ -149,7 +149,10 @@ export class MastoApi {
149
149
  // A viewer-mode agent (another agent holds the drain lease) may not act —
150
150
  // but a user acting HERE outranks the idle active agent elsewhere, so a
151
151
  // write attempt claims the lease and proceeds. Only a failed claim 503s.
152
- if (this.agent.viewer && req.method !== 'GET' && req.method !== 'HEAD') {
152
+ // An agent working from a copy its gateway shares (the browser build,
153
+ // web/app/copy-mode.mjs) asks every time, since an app at the gateway may
154
+ // have taken the lease without it hearing yet.
155
+ if ((this.agent.viewer || this.agent.copy) && req.method !== 'GET' && req.method !== 'HEAD') {
153
156
  const took = await this.agent.requestTakeover?.();
154
157
  if (!took) return send(503, { error: 'another agent is active for this pod — takeover failed, try again' });
155
158
  }
@@ -45,7 +45,7 @@ const parseRedirects = (v) => (Array.isArray(v) ? v : String(v || '').split(/\s+
45
45
  // The sign-in page for an account whose owner signs in at their pod: no
46
46
  // password of ours exists, and none is asked for. The page carries the
47
47
  // client's request in its address, signs the owner in at the pod with the
48
- // fediverse-account library, and proves that sign-in back to /oauth/authorize.
48
+ // fediverse-session library, and proves that sign-in back to /oauth/authorize.
49
49
  function sendPodSigninPage(res, client, webId, mount = '') {
50
50
  let asking = '';
51
51
  if (client && (client.name || client.redirect)) {
@@ -112,6 +112,9 @@ export async function handle(api, ctx) {
112
112
  };
113
113
  sched.push(entry);
114
114
  api.store.setScheduled(sched);
115
+ // Whatever keeps the time for this agent hears of it (a browser app
116
+ // tells its gateway, which publishes it if the app has closed by then).
117
+ api.agent.onScheduled?.();
115
118
  return send(200, api.scheduledJson(entry));
116
119
  }
117
120
  let note;
@@ -55,18 +55,22 @@ export class AcctFeed {
55
55
  });
56
56
  }
57
57
 
58
- start() {
58
+ // `lastSweptAt` and `onSwept`: see TagFeed.start — a restarted browser
59
+ // worker sweeps only when the interval is due.
60
+ start({ lastSweptAt = 0, onSwept = null } = {}) {
59
61
  this.stopped = false;
60
- this.sweep().catch(e => this.log(`acctfeed: ${e.message}`));
61
- const tick = () => {
62
- this.timer = setTimeout(() => {
63
- this.sweep()
64
- .catch(e => this.log(`acctfeed: ${e.message}`))
65
- .finally(() => { if (!this.stopped) tick(); });
66
- }, Math.round(this.config().intervalMin * 60_000 * (0.85 + Math.random() * 0.3)));
62
+ const sweep = () => this.sweep()
63
+ .catch(e => this.log(`acctfeed: ${e.message}`))
64
+ .finally(() => onSwept?.(Date.now()));
65
+ const every = () => Math.round(this.config().intervalMin * 60_000 * (0.85 + Math.random() * 0.3));
66
+ const tick = (ms) => {
67
+ this.timer = setTimeout(() => { sweep().finally(() => { if (!this.stopped) tick(every()); }); }, ms);
67
68
  this.timer.unref?.();
68
69
  };
69
- tick();
70
+ const due = lastSweptAt + this.config().intervalMin * 60_000 - Date.now();
71
+ if (due > 0) { tick(due); return; }
72
+ sweep();
73
+ tick(every());
70
74
  }
71
75
 
72
76
  stop() { this.stopped = true; clearTimeout(this.timer); }
@@ -52,18 +52,22 @@ export class BskyFeed {
52
52
  return this.config();
53
53
  }
54
54
 
55
- start() {
55
+ // `lastSweptAt` and `onSwept`: see TagFeed.start — a restarted browser
56
+ // worker sweeps only when the interval is due.
57
+ start({ lastSweptAt = 0, onSwept = null } = {}) {
56
58
  this.stopped = false;
57
- this.sweep().catch(e => this.log(`bskyfeed: ${e.message}`));
58
- const tick = () => {
59
- this.timer = setTimeout(() => {
60
- this.sweep()
61
- .catch(e => this.log(`bskyfeed: ${e.message}`))
62
- .finally(() => { if (!this.stopped) tick(); });
63
- }, Math.round(this.config().intervalMin * 60_000 * (0.85 + Math.random() * 0.3)));
59
+ const sweep = () => this.sweep()
60
+ .catch(e => this.log(`bskyfeed: ${e.message}`))
61
+ .finally(() => onSwept?.(Date.now()));
62
+ const every = () => Math.round(this.config().intervalMin * 60_000 * (0.85 + Math.random() * 0.3));
63
+ const tick = (ms) => {
64
+ this.timer = setTimeout(() => { sweep().finally(() => { if (!this.stopped) tick(every()); }); }, ms);
64
65
  this.timer.unref?.();
65
66
  };
66
- tick();
67
+ const due = lastSweptAt + this.config().intervalMin * 60_000 - Date.now();
68
+ if (due > 0) { tick(due); return; }
69
+ sweep();
70
+ tick(every());
67
71
  }
68
72
 
69
73
  stop() { this.stopped = true; clearTimeout(this.timer); }
@@ -72,20 +72,25 @@ export class TagFeed {
72
72
  return this.config();
73
73
  }
74
74
 
75
- start() {
75
+ // `lastSweptAt` and `onSwept` are for a browser worker the browser stops and
76
+ // restarts: it keeps the time of its last sweep, so a restart sweeps only
77
+ // when the interval is due rather than on every wake.
78
+ start({ lastSweptAt = 0, onSwept = null } = {}) {
76
79
  this.stopped = false; // restartable, the same way Intake.start is
77
- this.sweep().catch(e => this.log(`tagfeed: ${e.message}`));
80
+ const sweep = () => this.sweep()
81
+ .catch(e => this.log(`tagfeed: ${e.message}`))
82
+ .finally(() => onSwept?.(Date.now()));
78
83
  // Jittered and self-scheduling: every agent polling the same instance on
79
84
  // the same 15-minute boundary is a beat nobody asked for.
80
- const tick = () => {
81
- this.timer = setTimeout(() => {
82
- this.sweep()
83
- .catch(e => this.log(`tagfeed: ${e.message}`))
84
- .finally(() => { if (!this.stopped) tick(); });
85
- }, Math.round(this.config().intervalMin * 60_000 * (0.85 + Math.random() * 0.3)));
85
+ const every = () => Math.round(this.config().intervalMin * 60_000 * (0.85 + Math.random() * 0.3));
86
+ const tick = (ms) => {
87
+ this.timer = setTimeout(() => { sweep().finally(() => { if (!this.stopped) tick(every()); }); }, ms);
86
88
  this.timer.unref?.();
87
89
  };
88
- tick();
90
+ const due = lastSweptAt + this.config().intervalMin * 60_000 - Date.now();
91
+ if (due > 0) { tick(due); return; }
92
+ sweep();
93
+ tick(every());
89
94
  }
90
95
 
91
96
  // The flag is what makes this stick. Clearing the timer only cancels a sweep