fedipod 1.39.0 → 1.42.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 (100) hide show
  1. package/README.md +20 -7
  2. package/architecture.md +9 -3
  3. package/architecture.svg +93 -85
  4. package/gateway.md +73 -7
  5. package/lib/client/c2s.mjs +16 -5
  6. package/lib/client/masto/bridge.mjs +61 -0
  7. package/lib/client/masto/index.mjs +4 -1
  8. package/lib/client/masto/oauth.mjs +2 -2
  9. package/lib/client/masto/statuses.mjs +3 -0
  10. package/lib/connections/acctfeed.mjs +13 -9
  11. package/lib/connections/bskyfeed.mjs +13 -9
  12. package/lib/connections/tagfeed.mjs +14 -9
  13. package/lib/connections/vault.mjs +1 -1
  14. package/lib/core/agent.mjs +720 -0
  15. package/lib/core/gateway-move.mjs +58 -0
  16. package/lib/core/intake/index.mjs +193 -46
  17. package/lib/core/intake/verify.mjs +11 -0
  18. package/lib/core/keys.mjs +1 -1
  19. package/lib/core/lease.mjs +19 -4
  20. package/lib/core/pod-only.mjs +4 -0
  21. package/lib/core/publisher/index.mjs +6 -6
  22. package/lib/core/publisher/notes.mjs +73 -4
  23. package/lib/core/remote.mjs +54 -0
  24. package/lib/core/scheduled.mjs +39 -0
  25. package/lib/core/shapes/shapes-text.mjs +11 -3
  26. package/lib/core/storage.mjs +67 -0
  27. package/lib/core/store.mjs +66 -4
  28. package/lib/core/wire.mjs +4 -1
  29. package/lib/device/admin/index.mjs +4 -11
  30. package/lib/device/admin/routes/setup.mjs +60 -5
  31. package/lib/device/admin/server.mjs +18 -5
  32. package/lib/device/cli/commands/account.mjs +1 -1
  33. package/lib/device/cli/commands/run.mjs +2 -2
  34. package/lib/device/cli/commands/service.mjs +1 -1
  35. package/lib/device/cli/commands/setup.mjs +1 -1
  36. package/lib/device/cli/commands/state.mjs +1 -1
  37. package/lib/device/cli/context.mjs +1 -1
  38. package/lib/{gateway → device}/directory.mjs +1 -1
  39. package/lib/device/gateway-move.mjs +3 -58
  40. package/lib/device/home.mjs +3 -50
  41. package/lib/{client → device}/localapi.mjs +2 -2
  42. package/lib/device/remote.mjs +3 -54
  43. package/lib/gateway/account-agent.mjs +111 -0
  44. package/lib/gateway/copy.mjs +387 -0
  45. package/lib/gateway/front-core.mjs +30 -25
  46. package/lib/gateway/front-pages.mjs +31 -0
  47. package/lib/gateway/gateway-core.mjs +20 -1
  48. package/lib/gateway/held-mail.mjs +198 -0
  49. package/lib/gateway/keeper-due.mjs +39 -0
  50. package/lib/gateway/keeper-session.mjs +19 -0
  51. package/lib/gateway/keeper.mjs +72 -0
  52. package/lib/gateway/masto-gateway.mjs +559 -0
  53. package/lib/gateway/outbox-door.mjs +31 -0
  54. package/lib/gateway/quiet.mjs +7 -2
  55. package/lib/gateway/relay-extras.mjs +6 -6
  56. package/lib/gateway/state-api.mjs +239 -0
  57. package/lib/pod/containers.mjs +17 -0
  58. package/lib/pod/transport.mjs +43 -2
  59. package/lib/server/embed.mjs +4 -4
  60. package/lib/session/README.md +5 -5
  61. package/lib/session/demo.html +1 -1
  62. package/lib/session/package.json +1 -1
  63. package/lib/shared/files.mjs +49 -0
  64. package/lib/shared/ua.mjs +3 -0
  65. package/lib/surface/index.mjs +7 -0
  66. package/lib/{device/admin → surface}/origins.mjs +1 -1
  67. package/lib/{device/admin → surface}/routes/gateway.mjs +7 -11
  68. package/lib/{device/admin → surface}/routes/lifecycle.mjs +3 -3
  69. package/lib/{device/admin → surface}/routes/owner.mjs +4 -60
  70. package/lib/{device/admin → surface}/routes/social.mjs +1 -1
  71. package/lib/{device/admin → surface}/static.mjs +6 -1
  72. package/lib/{device/admin → surface}/surface.mjs +19 -16
  73. package/package.json +23 -5
  74. package/run-agent.mjs +18 -726
  75. package/scripts/build-app.mjs +4 -0
  76. package/scripts/check-netlify-bundles.mjs +84 -0
  77. package/scripts/check-pod-calls.mjs +42 -0
  78. package/scripts/stage-site.mjs +17 -4
  79. package/scripts/vendor-sanitize.mjs +27 -0
  80. package/vendor/idp-grant.cjs +11 -5
  81. package/vendor/sanitize-html.cjs +11456 -0
  82. package/web/admin/gateway.js +14 -1
  83. package/web/admin/index.html +4 -0
  84. package/web/admin/oauth-signin.mjs +1 -1
  85. package/web/app/README.md +1 -1
  86. package/web/app/admin-facade.mjs +11 -1
  87. package/web/app/agent.mjs +222 -52
  88. package/web/app/boot.mjs +3 -0
  89. package/web/app/copy-mode.mjs +247 -0
  90. package/web/app/dist/boot.js +68 -2
  91. package/web/app/dist/boot.js.map +4 -4
  92. package/web/app/dist/sw.js +1139 -222
  93. package/web/app/dist/sw.js.map +4 -4
  94. package/web/app/sw-src.mjs +15 -53
  95. package/web/app/update.js +3 -2
  96. package/web/app/warm-start.mjs +115 -0
  97. package/web/app-signin/app-signin.mjs +78 -0
  98. package/web/app-signin/index.html +49 -0
  99. package/browser.svg +0 -72
  100. /package/lib/{device/admin → surface}/routes/connections.mjs +0 -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
 
@@ -80,6 +82,11 @@ If your account turns a post down when it runs, you get a direct message from
80
82
  yourself saying what was refused and why. Signed in, your outbox shows you
81
83
  everything you've done, likes and follows included; everyone else sees only
82
84
  your public posts.
85
+ While fedipod.net keeps your account running, a post from such an app goes out
86
+ in a moment, whether or not FediPod is open.
87
+ A comment you post from dokieli names nobody to send it to, so it goes to
88
+ nobody. It is kept on your pod at its own address, where the page you
89
+ commented on points to it. Your own timeline shows it as a private post.
83
90
 
84
91
  **The manage page.** `manage`, in the bar's account group, opens it: your profile,
85
92
  aliases, the gateway, key rotation, recovering posts, parking, moving to
@@ -92,14 +99,16 @@ there it takes over; the first drops back to reading.
92
99
 
93
100
  ## What the browser version does not do
94
101
 
95
- - **Scheduled posts.** Nothing runs between now and the time you picked, so
102
+ - **Scheduled posts, if you stop fedipod.net keeping your account running.**
103
+ It does by default, and publishes them while FediPod is closed. Turned off
104
+ on the manage page, nothing runs between now and the time you picked, so
96
105
  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.
106
+ - **Notifications, and other clients, if you stop fedipod.net keeping your
107
+ account running.** While it does (the default), any Mastodon app, phone or
108
+ desktop, can use your account with `fedipod.net` as its server, and an app
109
+ that offers notifications gets them on your phone with everything closed.
99
110
  - **Live updates.** The client refreshes by polling.
100
111
  - **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
112
 
104
113
  For these, see [Other ways to run FediPod](#other-ways-to-run-fedipod).
105
114
 
@@ -108,6 +117,10 @@ For these, see [Other ways to run FediPod](#other-ways-to-run-fedipod).
108
117
  Everything you publish and everything you read is stored on your pod. Your
109
118
  signing key is stored there too, in a container only you can read through
110
119
  your pod's login.
120
+ While fedipod.net keeps your account running, its own pod identity can read
121
+ that container too, so it can act for you while FediPod is closed, and it
122
+ works from a copy of your account's data that it writes back to your pod every
123
+ fifteen minutes. Your key stays on your pod only.
111
124
  It all lives in the `fedipod` container you chose at sign-up. Your pod's
112
125
  public type index records it, as an ActivityStreams actor, which is how
113
126
  FediPod and other Solid apps find your account again.
@@ -119,7 +132,7 @@ not change.
119
132
 
120
133
  ## Other ways to use FediPod
121
134
 
122
- - [fediverse-account]() - an ESM library that supports both Solid-based and regular Fediverse accounts with methods to login, reply, boost, etc.
135
+ - [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
136
  - [FediPod Server](packages/fedipod-server/README.md): a full ActivityPub
124
137
  server as a Community Solid Server component, giving every pod on the server
125
138
  the option of a Fediverse account.
package/architecture.md CHANGED
@@ -16,13 +16,19 @@ 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.
22
30
 
23
- ![The browser version](https://raw.githubusercontent.com/jeff-zucker/FediPod/main/browser.svg)
24
-
25
- ![The DeviceAgent, with a gateway](https://raw.githubusercontent.com/jeff-zucker/FediPod/main/architecture.svg)
31
+ ![FediPod: the BrowserAgent and the Gateway, with your pod, Mastodon apps and C2S apps](https://raw.githubusercontent.com/jeff-zucker/FediPod/main/architecture.svg)
26
32
 
27
33
  ## Protocol conformance
28
34
 
package/architecture.svg CHANGED
@@ -1,89 +1,97 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1290 800" width="1290" height="800" font-family="system-ui, sans-serif">
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1300 680" font-family="Arial, Helvetica, sans-serif">
2
2
  <defs>
3
- <marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
4
- <path d="M0,0 L10,5 L0,10 z" fill="#555"/>
3
+ <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
4
+ <path d="M0,0 L10,5 L0,10 z" fill="#333"/>
5
5
  </marker>
6
6
  </defs>
7
- <style>
8
- .t { font-size: 18px; font-weight: 600; fill: #1a1a19; }
9
- .d { font-size: 16px; fill: #333; }
10
- .sub { font-size: 15px; fill: #666; }
11
- .lbl { font-size: 15px; fill: #555; }
12
- .cc { font-size: 11px; font-weight: 400; font-style: italic; fill: #555; }
13
- .arrow { stroke: #555; stroke-width: 2; fill: none; marker-end: url(#arr); }
14
- .arrow2 { stroke: #555; stroke-width: 2; fill: none; marker-end: url(#arr); marker-start: url(#arr); }
15
- </style>
16
-
17
- <rect width="1290" height="800" fill="#ffffff"/>
18
-
19
- <!-- The open social web -->
20
- <rect x="210" y="30" width="870" height="92" rx="12" fill="#eef1f8" stroke="#7a88b8"/>
21
- <text class="t" x="645" y="64" text-anchor="middle">The open social web</text>
22
- <text class="d" x="645" y="94" text-anchor="middle">other servers: deliver mail to us, read what we publish</text>
23
-
24
- <!-- FediPod gateway -->
25
- <rect x="210" y="180" width="870" height="134" rx="12" fill="#f2ece0" stroke="#b09a5e" stroke-dasharray="7 5"/>
26
- <text class="t" x="645" y="214" text-anchor="middle">FediPod gateway</text>
27
- <text class="d" x="645" y="240" text-anchor="middle">(optional — Netlify or any always on box)</text>
28
- <text class="d" x="645" y="266" text-anchor="middle">checks each delivery's signature at the door;</text>
29
- <text class="d" x="645" y="288" text-anchor="middle">drops spam and forgeries before the pod</text>
30
-
31
- <!-- Pod -->
32
- <rect x="210" y="375" width="360" height="197" rx="12" fill="#e7f2e7" stroke="#6a9a6a"/>
33
- <text class="t" x="390" y="407" text-anchor="middle">Tamara's pod</text>
34
- <text class="sub" x="390" y="431" text-anchor="middle">any https, sub-domained, always on pod</text>
35
- <text class="d" x="390" y="459" text-anchor="middle">stores &amp; serves her public</text>
36
- <text class="d" x="390" y="481" text-anchor="middle">record in S2S AS2 format —</text>
37
- <text class="d" x="390" y="503" text-anchor="middle">actor, posts, collections;</text>
38
- <text class="d" x="390" y="525" text-anchor="middle">her inbox buffers mail</text>
39
- <text class="d" x="390" y="547" text-anchor="middle">even when her agent is off</text>
40
-
41
- <!-- Agent -->
42
- <rect x="720" y="375" width="360" height="197" rx="12" fill="#fdeeee" stroke="#b87a7a"/>
43
- <text class="t" x="900" y="407" text-anchor="middle">Tamara's FediPod Agent</text>
44
- <text class="sub" x="900" y="431" text-anchor="middle">on local device</text>
45
- <text class="d" x="900" y="459" text-anchor="middle">does the thinking:</text>
46
- <text class="d" x="900" y="481" text-anchor="middle">reads the buffered mail,</text>
47
- <text class="d" x="900" y="503" text-anchor="middle">accepts follows, builds her</text>
48
- <text class="d" x="900" y="525" text-anchor="middle">timeline, signs &amp; delivers</text>
49
- <text class="d" x="900" y="547" text-anchor="middle">everything she says</text>
50
-
51
- <!-- Client -->
52
- <rect x="720" y="628" width="360" height="136" rx="12" fill="#f0e9f5" stroke="#9a7ab0"/>
53
- <text class="t" x="900" y="662" text-anchor="middle">Browser or Native App</text>
54
- <text class="sub" x="900" y="686" text-anchor="middle">(Phanpy, Whalebird, …)</text>
55
- <text class="d" x="900" y="714" text-anchor="middle">where she reads and posts;</text>
56
- <text class="d" x="900" y="736" text-anchor="middle">talks only to her agent</text>
57
-
58
- <!-- open social web -> gateway -->
59
- <path class="arrow" d="M 645 124 L 645 176"/>
60
- <text class="lbl" x="657" y="155">deliveries in</text>
61
-
62
- <!-- gateway -> pod -->
63
- <path class="arrow" d="M 390 316 L 390 371"/>
64
- <text class="lbl" x="402" y="336">verified mail</text>
65
- <text class="lbl" x="402" y="357">+ receipt</text>
66
-
67
- <!-- pod <-> agent -->
68
- <path class="arrow2" d="M 572 473 L 718 473"/>
69
- <text class="lbl" x="645" y="455" text-anchor="middle">drains mail,</text>
70
- <text class="lbl" x="645" y="497" text-anchor="middle">writes the record</text>
71
-
72
- <!-- agent <-> client -->
73
- <path class="arrow2" d="M 900 574 L 900 626"/>
74
- <text class="lbl" x="912" y="605">read / post</text>
75
-
76
- <!-- pod -> open social web: read directly, never through the gateway -->
77
- <path class="arrow" d="M 208 440 L 70 440 C 45 440, 38 400, 38 330 C 38 210, 88 100, 206 100"/>
78
- <text class="lbl" x="188" y="412" text-anchor="end">reads her record</text>
79
-
80
- <!-- agent -> open social web: delivers her own posts, never through the gateway -->
81
- <path class="arrow" d="M 1082 440 L 1220 440 C 1245 440, 1252 400, 1252 330 C 1252 210, 1202 78, 1084 78"/>
82
- <text class="lbl" x="1102" y="412">signed deliveries out</text>
83
-
84
- <text class="d" x="45" y="716">The gateway stops unwanted mail from reaching the pod.</text>
85
- <text class="d" x="45" y="740">The agent prevents mail buildup and does the mail processing.</text>
86
- <text class="d" x="45" y="764">This leaves the pod with the minimal possible load.</text>
87
-
88
- <text class="cc" x="1245" y="764" text-anchor="end">(cc) 4.0 By, Jeff Zucker, 2026</text>
7
+ <rect x="0" y="0" width="1300" height="680" fill="#ffffff"/>
8
+
9
+ <!-- boxes (draw.io palette, as browser.svg) -->
10
+ <g stroke-width="1.5">
11
+ <rect x="450" y="24" width="280" height="84" rx="6" fill="#dae8fc" stroke="#6c8ebf"/>
12
+ <rect x="260" y="175" width="650" height="215" rx="10" fill="#f5f5f5" stroke="#666666" stroke-dasharray="8 6"/>
13
+ <rect x="285" y="230" width="230" height="130" rx="6" fill="#d5e8d4" stroke="#82b366"/>
14
+ <rect x="665" y="230" width="230" height="130" rx="6" fill="#e1d5e7" stroke="#9673a6"/>
15
+ <rect x="40" y="500" width="300" height="110" rx="6" fill="#f8cecc" stroke="#b85450"/>
16
+ <rect x="780" y="500" width="300" height="110" rx="6" fill="#fff2cc" stroke="#d6b656"/>
17
+ <rect x="1040" y="250" width="220" height="95" rx="6" fill="#ffe6cc" stroke="#d79b00"/>
18
+ </g>
19
+
20
+ <!-- box labels -->
21
+ <g fill="#111" text-anchor="middle">
22
+ <text x="590" y="60" font-size="20" font-weight="bold">Open Social Web</text>
23
+ <text x="590" y="88" font-size="16">Mastodon · Pixelfed · Bluesky …</text>
24
+
25
+ <text x="280" y="206" font-size="22" font-weight="bold" text-anchor="start">FediPod</text>
26
+
27
+ <text x="400" y="266" font-size="20" font-weight="bold">BrowserAgent</text>
28
+ <text x="400" y="294" font-size="16">FediPod in any browser,</text>
29
+ <text x="400" y="318" font-size="16">with Sengi or Phanpy</text>
30
+
31
+ <text x="780" y="266" font-size="20" font-weight="bold">Gateway</text>
32
+ <text x="780" y="294" font-size="16">at fedipod.net, keeps</text>
33
+ <text x="780" y="318" font-size="16">your account running</text>
34
+ <text x="780" y="342" font-size="16">from a working copy</text>
35
+
36
+ <text x="190" y="536" font-size="20" font-weight="bold">Your Pod</text>
37
+ <text x="190" y="564" font-size="16">your key, your posts</text>
38
+ <text x="190" y="588" font-size="16">and your account's data</text>
39
+
40
+ <text x="930" y="536" font-size="20" font-weight="bold">Mastodon Apps</text>
41
+ <text x="930" y="564" font-size="16">Elk · Ivory · Tusky · Fedilab</text>
42
+ <text x="930" y="588" font-size="16">on your phone or desktop</text>
43
+
44
+ <text x="1150" y="289" font-size="20" font-weight="bold">C2S Apps</text>
45
+ <text x="1150" y="317" font-size="16">box, Dokieli</text>
46
+ </g>
47
+
48
+ <!-- arrows -->
49
+ <g stroke="#333" stroke-width="1.6" fill="none">
50
+ <!-- Open Social Web <-> BrowserAgent (ATProto, Bluesky) -->
51
+ <line x1="520" y1="108" x2="410" y2="230" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
52
+ <!-- Open Social Web <-> Gateway (ActivityPub mail) -->
53
+ <line x1="660" y1="108" x2="770" y2="230" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
54
+ <!-- BrowserAgent <-> Gateway -->
55
+ <line x1="515" y1="298" x2="665" y2="298" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
56
+ <!-- Your Pod -> Open Social Web (public posts and profile) -->
57
+ <path d="M100,500 C0,400 0,110 450,70" marker-end="url(#arrow)"/>
58
+ <!-- BrowserAgent <-> Your Pod (Solid) -->
59
+ <line x1="350" y1="360" x2="230" y2="500" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
60
+ <!-- Gateway <-> Your Pod (stores the account's data) -->
61
+ <line x1="720" y1="360" x2="340" y2="560" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
62
+ <!-- Mastodon Apps <-> Gateway -->
63
+ <line x1="900" y1="500" x2="840" y2="360" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
64
+ <!-- C2S Apps <-> Gateway -->
65
+ <line x1="1040" y1="298" x2="895" y2="298" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
66
+ </g>
67
+
68
+ <!-- arrow labels -->
69
+ <g fill="#333" font-size="16">
70
+ <text x="470" y="140" text-anchor="end" font-weight="bold" fill="#111">ATProto Protocol</text>
71
+ <text x="470" y="160" text-anchor="end">for Bluesky</text>
72
+
73
+ <text x="712" y="140" text-anchor="start" font-weight="bold" fill="#111">S2S Protocol</text>
74
+ <text x="712" y="160" text-anchor="start">mail in, posts out</text>
75
+
76
+ <text x="590" y="286" text-anchor="middle">serves the app</text>
77
+ <text x="590" y="320" text-anchor="middle">posts out</text>
78
+
79
+ <text x="968" y="286" text-anchor="middle" font-weight="bold" fill="#111">C2S Protocol</text>
80
+
81
+ <text x="85" y="250" text-anchor="start">other servers read</text>
82
+ <text x="85" y="270" text-anchor="start">your public posts</text>
83
+ <text x="85" y="290" text-anchor="start">and profile</text>
84
+
85
+ <text x="272" y="420" text-anchor="end" font-weight="bold" fill="#111">Solid Protocol</text>
86
+ <text x="272" y="440" text-anchor="end">reads &amp; writes your pod</text>
87
+
88
+ <text x="520" y="490" text-anchor="start">stores the working copy</text>
89
+ <text x="520" y="510" text-anchor="start">on your pod</text>
90
+
91
+ <text x="894" y="415" text-anchor="start" font-weight="bold" fill="#111">Mastodon client API</text>
92
+ <text x="894" y="435" text-anchor="start">sign in at fedipod.net;</text>
93
+ <text x="894" y="455" text-anchor="start">notifications to your phone</text>
94
+ </g>
95
+
96
+ <text x="1260" y="660" text-anchor="end" font-size="16" fill="#555">© 2026 Jeff Zucker · CC BY-SA 4.0</text>
89
97
  </svg>
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.
@@ -114,6 +117,11 @@ there, signed in at your pod. The door checks that the token is yours, puts the
114
117
  post in your inbox marked as yours, and answers with the address the post will
115
118
  have. Your agent publishes it and sends it to your followers the next time it
116
119
  runs: for a browser account, the next time you open the site.
120
+ While the gateway keeps a browser account running, it starts the account's
121
+ keeper as the post arrives, so the post goes out in a moment.
122
+ A comment from dokieli names nobody to send it to, so it is sent to nobody. It
123
+ is kept at its own address for the page it is on to point at, and only your
124
+ own outbox lists it.
117
125
 
118
126
  A post with no audience of its own goes out as a public post. A post that is
119
127
  not a note, an annotation say, is kept as the app sent it, under your name.
@@ -153,10 +161,61 @@ on the setup page). Setup reads the account on the pod, keeps its state and
153
161
  key there, attaches at the new gateway, and completes the move when the
154
162
  agent first acts. See [the DeviceAgent](device-agent.md).
155
163
 
164
+ ## While your app is closed
165
+
166
+ A browser account runs only while FediPod is open somewhere. While it is
167
+ closed, fedipod.net does two things for it.
168
+
169
+ **Your mail waits at the gateway.** Deliveries are kept there, not written to
170
+ your pod one by one, and reach your pod in batches of up to a hundred: when you
171
+ open FediPod, or every fifteen minutes otherwise. While FediPod is open, mail
172
+ goes straight to your pod as before. A DeviceAgent's mail always goes straight
173
+ to its pod.
174
+
175
+ **The gateway keeps your account running.** Unless you turn it off, the
176
+ gateway accepts your follows, tries again what failed to go out, and publishes
177
+ the posts you scheduled, all while FediPod is closed. Other mail waits on your
178
+ pod for FediPod to open, as before. It works under its own pod identity, which your app
179
+ names in the access rules on your FediPod folder. It reads your signing key
180
+ from your pod when it needs it, so it can post as you, and keeps no copy of the
181
+ key. **Stop keeping it running** on the manage page takes it out of the rules.
182
+ If fedipod.net ever changes its own pod identity, your FediPod moves your
183
+ account over the next time you open it, with nothing for you to do.
184
+ Scheduling a post is offered only while this is on, because otherwise nothing
185
+ would be running when the time came.
186
+
187
+ **Your account's working data is kept at the gateway.** While the gateway
188
+ keeps your account running, your timeline, notifications, followers, settings
189
+ and the rest of your FediPod state are kept at fedipod.net, and FediPod in your
190
+ browser, the gateway and any app you use all work from that one copy, so they
191
+ never disagree. fedipod.net writes what changed to your pod every fifteen
192
+ minutes, and everything at once when you stop it keeping your account running,
193
+ move your address or close it. Your signing key and the passwords of accounts
194
+ you connected elsewhere are never in the copy; they stay on your pod only.
195
+
196
+ ## Using any Mastodon app
197
+
198
+ While the gateway keeps your account running, any Mastodon app can use it:
199
+ elk.zone, Ivory, Tusky, Phanpy and the rest. In the app, give `fedipod.net` as
200
+ your server. The app sends you to a fedipod.net page that asks for your
201
+ Fediverse address or your WebID and signs you in at your own pod; no password
202
+ is typed on fedipod.net. The app then reads your timeline and notifications from your
203
+ account's copy at the gateway, and posts, boosts, likes and follows as you.
204
+
205
+ An app checks for new posts every minute or so while it is open; there are no
206
+ live updates. Accounts are made on the fedipod.net front page, with a pod, not
207
+ from an app.
208
+
209
+ **Notifications on your phone.** An app that offers notifications can sign up
210
+ for them here. A mention, a reply, a direct message, a follow, or a like, boost
211
+ or reaction to your own post then reaches your phone as it arrives, while
212
+ FediPod and the app are closed. While FediPod is open in a browser, it reads
213
+ your mail itself, and those arrivals are not pushed.
214
+
156
215
  ## Accounts that go quiet
157
216
 
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
217
+ Every delivery the gateway accepts ends up in your pod inbox, and your agent
218
+ reads it from there. A BrowserAgent reads only while
160
219
  its page is open, so an account nobody opens grows on its pod without limit
161
220
  and comes back to a drain of everything at once. fedipod.net keeps two facts
162
221
  about each browser account — when its owner last signed in or posted, and
@@ -194,9 +253,16 @@ web address becomes a link.
194
253
 
195
254
  It reads only public data to decide what concerns you: your published
196
255
  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.
256
+ with a mirror of your blocklist. Publishing that mirror does make your
257
+ blocklist public, which is part of the bargain of running behind a door.
258
+
259
+ Three things go further, all for browser accounts only. Mail that waits at
260
+ the gateway while your app is closed, direct messages included, is stored there
261
+ until it reaches your pod. A gateway keeping your account running can read and
262
+ write everything in your FediPod folder, your signing key included, while it
263
+ works; nothing else on your pod is open to it. And it keeps your account's
264
+ working data — your timeline, notifications and direct messages among it — for
265
+ as long as it keeps your account running.
200
266
 
201
267
  ## Running a gateway
202
268
 
@@ -48,6 +48,10 @@ const ACTIVITY_TYPES = new Set([
48
48
 
49
49
  const arr = (v) => (v === undefined || v === null ? [] : Array.isArray(v) ? v : [v]);
50
50
  const idOf = (v) => (typeof v === 'string' ? v : v?.id || null);
51
+ // An audience address. The public collection has three valid spellings
52
+ // (ActivityPub §5.6); reading a post as JSON-LD can yield the short one.
53
+ const PUBLIC_SPELLINGS = new Set(['Public', 'as:Public']);
54
+ const audienceOf = (v) => { const id = idOf(v); return PUBLIC_SPELLINGS.has(id) ? wire.PUBLIC : id; };
51
55
 
52
56
  function readBody(req) {
53
57
  return new Promise((resolve, reject) => {
@@ -63,6 +67,9 @@ function readBody(req) {
63
67
  });
64
68
  }
65
69
 
70
+ const isAnnotation = (o) => arr(o?.type).some((t) => t === 'Annotation' || t === 'oa:Annotation'
71
+ || t === 'http://www.w3.org/ns/oa#Annotation');
72
+
66
73
  export class C2S {
67
74
  constructor({ agent, log = console.log, auth, scheme = null, mount = '' }) {
68
75
  this.agent = agent;
@@ -345,8 +352,8 @@ export class C2S {
345
352
  // every client means by a post with no audience chosen, and what a client
346
353
  // that never addresses (dokieli's annotations) needs.
347
354
  visibilityOf(activity, object) {
348
- const to = arr(activity.to ?? object?.to).map(idOf);
349
- const cc = arr(activity.cc ?? object?.cc).map(idOf);
355
+ const to = arr(activity.to ?? object?.to).map(audienceOf);
356
+ const cc = arr(activity.cc ?? object?.cc).map(audienceOf);
350
357
  // Blind copies alone are still an audience: a post for them and nobody
351
358
  // else is direct, never public.
352
359
  const blind = arr(activity.bto ?? object?.bto).length + arr(activity.bcc ?? object?.bcc).length;
@@ -386,7 +393,7 @@ export class C2S {
386
393
  // in to/cc are listed and delivered to; the ones in bto/bcc are delivered
387
394
  // to and never listed (§6).
388
395
  addressedActors(activity, object) {
389
- const pick = (...fields) => [...new Set(fields.flatMap((f) => arr(activity[f] ?? object?.[f]).map(idOf)))]
396
+ const pick = (...fields) => [...new Set(fields.flatMap((f) => arr(activity[f] ?? object?.[f]).map(audienceOf)))]
390
397
  .filter((a) => typeof a === 'string' && /^https?:\/\//u.test(a)
391
398
  && a !== wire.PUBLIC && a !== this.urls.followers && a !== this.urls.actor);
392
399
  // `audience` is who the post is for, a group most often: delivered to and
@@ -417,12 +424,16 @@ export class C2S {
417
424
  const visibility = this.visibilityOf(activity, object);
418
425
  const { also, deliverTo } = this.addressedActors(activity, object);
419
426
  // Not a Note and not a poll: stored as sent, under this actor, and
420
- // the Create around it delivered — a Web Annotation, for one.
427
+ // the Create around it delivered — a Web Annotation, for one. An
428
+ // annotation addressed to nobody, as dokieli sends one, is delivered
429
+ // to nobody: kept at its address for the page it is on to point at.
421
430
  if (makes !== 'Note' && makes !== 'Question') {
422
431
  const asSent = raw && typeof raw === 'object' && !Array.isArray(raw)
423
432
  ? (ACTIVITY_TYPES.has(raw.type) ? (raw.object && typeof raw.object === 'object' ? raw.object : null) : raw)
424
433
  : null;
425
- const made = await agent.publisher.publishObject(asSent || object, { visibility, slug, also, deliverTo });
434
+ const unaddressed = isAnnotation(asSent || object)
435
+ && ['to', 'cc', 'bto', 'bcc', 'audience'].every((f) => !arr(activity[f] ?? object[f]).length);
436
+ const made = await agent.publisher.publishObject(asSent || object, { visibility, slug, also, deliverTo, unaddressed });
426
437
  return reply(201, { id: made.createId, object: made.id }, { location: made.createId });
427
438
  }
428
439
  // `source.content` is the client's plain text when it sends one; bare
@@ -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)) {
@@ -53,7 +53,7 @@ function sendPodSigninPage(res, client, webId, mount = '') {
53
53
  try { where = client.redirect ? new URL(client.redirect).host : ''; } catch { /* oob or blank */ }
54
54
  const who = client.name ? escapeHtml(client.name) : (where ? escapeHtml(where) : 'A client');
55
55
  asking = `<p><strong>${who}</strong> is asking to use your account`
56
- + `${where ? `, and will be sent back to <code>${escapeHtml(where)}</code>` : ''}.</p>`;
56
+ + `${where ? `, and you will be sent back to <code>${escapeHtml(where)}</code>` : ''}.</p>`;
57
57
  }
58
58
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' });
59
59
  res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
@@ -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); }