fedipod 1.36.6 → 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 (94) hide show
  1. package/README.md +29 -8
  2. package/architecture.md +8 -0
  3. package/bin/fedipod.mjs +6 -0
  4. package/cli.md +7 -1
  5. package/device-agent.md +3 -3
  6. package/gateway.md +69 -8
  7. package/groups.md +2 -1
  8. package/gui.md +3 -1
  9. package/lib/client/c2s.mjs +97 -43
  10. package/lib/client/masto/accounts.mjs +1 -0
  11. package/lib/client/masto/bridge.mjs +61 -0
  12. package/lib/client/masto/index.mjs +4 -1
  13. package/lib/client/masto/oauth.mjs +1 -1
  14. package/lib/client/masto/statuses.mjs +3 -0
  15. package/lib/connections/acctfeed.mjs +13 -9
  16. package/lib/connections/bskyfeed.mjs +13 -9
  17. package/lib/connections/tagfeed.mjs +14 -9
  18. package/lib/core/deliver.mjs +17 -1
  19. package/lib/core/intake/activities.mjs +18 -2
  20. package/lib/core/intake/group.mjs +3 -1
  21. package/lib/core/intake/index.mjs +226 -48
  22. package/lib/core/intake/notes.mjs +12 -5
  23. package/lib/core/intake/verify.mjs +11 -0
  24. package/lib/core/lease.mjs +15 -1
  25. package/lib/core/place.mjs +82 -0
  26. package/lib/core/pod-only.mjs +4 -0
  27. package/lib/core/publisher/collections.mjs +37 -2
  28. package/lib/core/publisher/index.mjs +25 -1
  29. package/lib/core/publisher/notes.mjs +73 -7
  30. package/lib/core/publisher/own.mjs +143 -0
  31. package/lib/core/publisher/questions.mjs +5 -3
  32. package/lib/core/scheduled.mjs +39 -0
  33. package/lib/core/social.mjs +62 -32
  34. package/lib/core/storage.mjs +67 -0
  35. package/lib/core/store.mjs +77 -5
  36. package/lib/core/wire.mjs +51 -15
  37. package/lib/device/admin/routes/lifecycle.mjs +1 -1
  38. package/lib/device/admin/routes/setup.mjs +17 -1
  39. package/lib/device/cli/commands/setup.mjs +50 -8
  40. package/lib/device/cli/context.mjs +1 -1
  41. package/lib/device/migrate.mjs +1 -1
  42. package/lib/device/setup.mjs +45 -8
  43. package/lib/gateway/account-agent.mjs +111 -0
  44. package/lib/gateway/copy.mjs +381 -0
  45. package/lib/gateway/front-core.mjs +74 -63
  46. package/lib/gateway/gateway-core.mjs +107 -9
  47. package/lib/gateway/held-mail.mjs +198 -0
  48. package/lib/gateway/keeper-due.mjs +39 -0
  49. package/lib/gateway/keeper-session.mjs +10 -0
  50. package/lib/gateway/keeper.mjs +72 -0
  51. package/lib/gateway/masto-gateway.mjs +510 -0
  52. package/lib/gateway/quiet.mjs +7 -2
  53. package/lib/gateway/relay-extras.mjs +89 -0
  54. package/lib/gateway/state-api.mjs +207 -0
  55. package/lib/gateway/token-claims.mjs +16 -0
  56. package/lib/pod/containers.mjs +17 -0
  57. package/lib/pod/location.mjs +52 -0
  58. package/lib/pod/notes.mjs +2 -4
  59. package/lib/pod/transport.mjs +214 -24
  60. package/lib/pod/type-index.mjs +101 -0
  61. package/lib/pod/urls.mjs +6 -0
  62. package/lib/server/embed.mjs +7 -6
  63. package/lib/session/README.md +5 -5
  64. package/lib/session/demo.html +1 -1
  65. package/lib/session/fedi-account.mjs +19 -10
  66. package/lib/session/package.json +2 -2
  67. package/package.json +2 -2
  68. package/run-agent.mjs +6 -16
  69. package/scripts/stage-site.mjs +17 -4
  70. package/web/admin/actors.js +2 -0
  71. package/web/admin/gateway.js +14 -1
  72. package/web/admin/index.html +13 -0
  73. package/web/admin/oauth-signin.mjs +1 -1
  74. package/web/admin/record.js +4 -1
  75. package/web/admin/setup/index.html +17 -1
  76. package/web/admin/setup/setup.js +18 -5
  77. package/web/app/README.md +2 -2
  78. package/web/app/admin-facade.mjs +12 -2
  79. package/web/app/agent.mjs +228 -51
  80. package/web/app/boot.mjs +75 -32
  81. package/web/app/copy-mode.mjs +223 -0
  82. package/web/app/dist/boot.js +498 -88
  83. package/web/app/dist/boot.js.map +4 -4
  84. package/web/app/dist/sw.js +4016 -2539
  85. package/web/app/dist/sw.js.map +4 -4
  86. package/web/app/index.html +16 -0
  87. package/web/app/signup.mjs +65 -26
  88. package/web/app/sw-src.mjs +15 -53
  89. package/web/app/update.js +3 -2
  90. package/web/app/warm-start.mjs +115 -0
  91. package/web/app-signin/app-signin.mjs +78 -0
  92. package/web/app-signin/index.html +41 -0
  93. package/web/front/run.html +7 -1
  94. package/web/front/run.js +30 -4
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
 
@@ -23,6 +25,11 @@ This README is about the `FediPod BrowserAgent`. There are also a number of [oth
23
25
  2. Choose your pod provider. You'll be redirected to your provider to login if you have a pod or sign up if you need to create one.
24
26
  3. Back on fedipod.net, choose your handle and where your address lives:
25
27
  on your pod, `@handle@yourpod`, or at this site, `@handle@fedipod.net`.
28
+ Choose where on your pod your data goes: a container named `fedipod`,
29
+ inside whichever container you name (your pod's root if you leave it
30
+ empty). If your pod has no public type index yet, you're asked whether
31
+ to create one; FediPod records there where your account lives, and
32
+ nothing is set up without it.
26
33
  4. You see your Fediverse timeline.
27
34
 
28
35
  From now on, anyone can open your profile at `https://fedipod.net/@handle@provider`, signed in or not.
@@ -71,6 +78,10 @@ an alias, so a Move from it lands here.
71
78
  client-to-server, dokieli for one, can post as you. It sends to the outbox
72
79
  address in your actor document, which your WebID profile also names, signed in
73
80
  at your pod. The post goes out the next time you open fedipod.net.
81
+ If your account turns a post down when it runs, you get a direct message from
82
+ yourself saying what was refused and why. Signed in, your outbox shows you
83
+ everything you've done, likes and follows included; everyone else sees only
84
+ your public posts.
74
85
 
75
86
  **The manage page.** `manage`, in the bar's account group, opens it: your profile,
76
87
  aliases, the gateway, key rotation, recovering posts, parking, moving to
@@ -83,14 +94,16 @@ there it takes over; the first drops back to reading.
83
94
 
84
95
  ## What the browser version does not do
85
96
 
86
- - **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
87
100
  the composer refuses one rather than dropping it later.
88
- - **Notifications while the client is closed.** There is no push service.
89
- 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.
90
105
  - **Live updates.** The client refreshes by polling.
91
106
  - **Hosting a group.** Joining one works.
92
- - **Other clients.** A phone app or desktop client has nothing on the network
93
- to connect to.
94
107
 
95
108
  For these, see [Other ways to run FediPod](#other-ways-to-run-fedipod).
96
109
 
@@ -98,7 +111,15 @@ For these, see [Other ways to run FediPod](#other-ways-to-run-fedipod).
98
111
 
99
112
  Everything you publish and everything you read is stored on your pod. Your
100
113
  signing key is stored there too, in a container only you can read through
101
- your pod's login. fedipod.net holds no key: it verifies incoming mail, drops
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.
119
+ It all lives in the `fedipod` container you chose at sign-up. Your pod's
120
+ public type index records it, as an ActivityStreams actor, which is how
121
+ FediPod and other Solid apps find your account again.
122
+ fedipod.net holds no key: it verifies incoming mail, drops
102
123
  the junk, forwards the rest to your pod, and hands your browser the app.
103
124
  With an address on your pod, `@handle@yourpod`, you can detach from it at
104
125
  any time and attach to a gateway of your own; your address and your data do
@@ -106,7 +127,7 @@ not change.
106
127
 
107
128
  ## Other ways to use FediPod
108
129
 
109
- - [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.
110
131
  - [FediPod Server](packages/fedipod-server/README.md): a full ActivityPub
111
132
  server as a Community Solid Server component, giving every pod on the server
112
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/bin/fedipod.mjs CHANGED
@@ -25,6 +25,12 @@
25
25
  // --pod-name, --group, --summary, --icon, --root, --keys) keeps setup
26
26
  // entirely on the command line, as does a non-TTY stdin. --cli forces it.
27
27
  //
28
+ // --in <container> the container on the pod that holds `fedipod/`
29
+ // (asked when not given; empty is the pod's root)
30
+ // --create-type-index make a public type index when the pod has none;
31
+ // where the account lives is recorded there, and
32
+ // without one and without this, setup stops
33
+ //
28
34
  // The password is prompted (or AP_PASSWORD) — used once to create the
29
35
  // account and/or mint a revocable CSS client-credential, never stored.
30
36
  // Keys live in AP_HOME by default (the pod host cannot read them);
package/cli.md CHANGED
@@ -32,10 +32,16 @@ prompt. `--profile NAME` names the new identity when you have more than one.
32
32
  `--address pod` (the default) puts your address on your pod, `@you@yourpod`;
33
33
  `--address front` puts it at a gateway, `@you@the-gateway`, with your posts,
34
34
  key and data still on your pod. `--gateway <url>` names the gateway
35
- (`https://fedipod.net` by default). A pod on a suffix-based host cannot
35
+ (`https://fedipod.net` by default). A suffixed pod cannot
36
36
  answer WebFinger for a handle, so it always takes a gateway address, whichever
37
37
  `--address` you gave.
38
38
 
39
+ Your data goes in a container named `fedipod`, inside the container you name
40
+ with `--in <path>` (asked when you leave it out; empty is your pod's root). Where
41
+ it lives is recorded in your pod's public type index. If your pod has none,
42
+ setup asks before making one, and `--create-type-index` says yes in advance;
43
+ without a yes, setup stops before anything is written.
44
+
39
45
  ## Which identity a command acts on
40
46
 
41
47
  `--profile NAME` works on every command, not just `up`; `AP_PROFILE` is the
package/device-agent.md CHANGED
@@ -12,9 +12,9 @@ connect to it; and it can host a [group](groups.md).
12
12
  - Node 20 or newer.
13
13
  - A Solid pod, such as `https://alice.solidcommunity.net/` or
14
14
  `https://server.example/alice/`. The address is `@handle@yourpod`, or
15
- `@handle@fedipod.net` when the pod is on a path of a shared host, since
16
- the shared host cannot answer for the handle; the posts, key and data
17
- stay on the pod either way.
15
+ `@handle@fedipod.net` when the pod is suffixed, since a suffixed pod
16
+ cannot answer for the handle; the posts, key and data stay on the pod
17
+ either way.
18
18
  - Followers-only and direct posts need a pod that enforces WAC access control;
19
19
  on one that does not, the composer refuses those two and says why.
20
20
  - While the agent is off, your mail waits on your pod's host. Run it as a
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.
@@ -32,7 +35,7 @@ password is typed anywhere:
32
35
  restarts the agent itself to publish under it.
33
36
 
34
37
  In the BrowserAgent the same choice is made once, at sign-up, and cannot be
35
- changed afterwards. A pod on a suffix-based host, like
38
+ changed afterwards. A suffixed pod, like
36
39
  `https://server.example/alice/`, always takes the gateway-based name: nothing
37
40
  at that host answers for the handle, so the gateway does. Its posts, key and
38
41
  data stay on the pod.
@@ -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
 
package/groups.md CHANGED
@@ -60,7 +60,8 @@ A group that withdraws a post it carried to you takes it off your timeline too.
60
60
  ## Inviting people
61
61
 
62
62
  A group has a page anyone can open, at `ap/profile.html` under its pod's
63
- app container — `<pod>/fedipod/ap/profile.html`. It
63
+ app container — `<pod>/fedipod/ap/profile.html`
64
+ by default, or under the `fedipod` container wherever it was put at setup. It
64
65
  carries the group's address and a Follow box that sends a visitor to their
65
66
  own server's follow screen, so it is the link to put where people will find
66
67
  it. Posts the group carries appear in members' timelines as the group
package/gui.md CHANGED
@@ -46,7 +46,9 @@ its own port.
46
46
  ## Sharing an account
47
47
 
48
48
  Each identity has a page anyone can open, at `ap/profile.html` under its pod
49
- — for example `https://your-pod.example/fedipod/ap/profile.html`. It
49
+ — for example `https://your-pod.example/fedipod/ap/profile.html`
50
+ (the `fedipod` container is wherever you put it at setup; the manage page's
51
+ "stored at" row says where). It
50
52
  shows the name, bio and address, and offers a Follow box: a visitor types
51
53
  their own server and lands on that server's follow screen. Hand out that
52
54
  link, or the `@name@host` address itself, which works in the search box of
@@ -28,6 +28,7 @@ import * as social from '../core/social.mjs';
28
28
  import * as wire from '../core/wire.mjs';
29
29
  import { readLenient } from '../core/as2.mjs';
30
30
  import { safeSlug } from '../core/publisher/notes.mjs';
31
+ import * as podNotes from '../pod/notes.mjs';
31
32
 
32
33
  const MAX_BODY = 512 * 1024; // same ceiling the inbox drain enforces
33
34
 
@@ -192,9 +193,11 @@ export class C2S {
192
193
  * published copy and it stays the canonical one; this is served only where
193
194
  * that copy cannot be read by the client asking.
194
195
  */
195
- sendLocalOutbox(res, url, origin) {
196
+ sendLocalOutbox(res, url, origin, { owner = false } = {}) {
196
197
  const id = `${origin}ap/outbox`;
197
- const outbox = this.store.read('outbox.json', []); // newest first
198
+ // newest first; the owner's view is every message, the public one what a
199
+ // stranger may see
200
+ const outbox = this.store.read(owner ? 'outbox-own.json' : 'outbox.json', []);
198
201
  const page = url?.searchParams?.get('page') || null;
199
202
  const ct = { 'content-type': 'application/activity+json; charset=utf-8' };
200
203
  if (!page) {
@@ -270,12 +273,22 @@ export class C2S {
270
273
  return this.sendInbox(res, url);
271
274
  }
272
275
  if (req.method === 'GET' || req.method === 'HEAD') {
276
+ // The owner, signed in, reads every message the actor produced (§5.1:
277
+ // the outbox is filtered by who asks). Anyone else reads the public one.
278
+ const owner = pathname === '/ap/outbox' && req.headers.authorization
279
+ ? (await this.auth(req, pathname)).ok : false;
273
280
  // Where the client surface answers on an address a stranger can reach,
274
281
  // the pod's copies already name it and are the documents. Standalone
275
282
  // they are not reachable by the client asking, so it is answered here.
276
283
  const local = this.agent.publisher?.clientOrigin ? null : this.localOrigin(req);
277
284
  if (local && pathname === '/ap/actor') return this.sendLocalActor(req, res, local);
278
- if (local && pathname === '/ap/outbox') return this.sendLocalOutbox(res, url, local);
285
+ if (local && pathname === '/ap/outbox') return this.sendLocalOutbox(res, url, local, { owner });
286
+ // The owner is answered here, not sent on: a redirect loses the
287
+ // credential (a browser drops it across origins, and a DPoP proof names
288
+ // the address it was made for), so the pod would refuse them.
289
+ if (owner && pathname === '/ap/outbox') {
290
+ return this.sendLocalOutbox(res, url, this.agent.publisher.clientOrigin, { owner });
291
+ }
279
292
  // The pod's copy is the document; send the reader there.
280
293
  const target = pathname === '/ap/actor' ? this.urls.actor : this.urls.outbox;
281
294
  res.writeHead(303, { location: target, 'cache-control': 'no-store' });
@@ -334,14 +347,22 @@ export class C2S {
334
347
  visibilityOf(activity, object) {
335
348
  const to = arr(activity.to ?? object?.to).map(idOf);
336
349
  const cc = arr(activity.cc ?? object?.cc).map(idOf);
337
- if (!to.length && !cc.length) return 'public';
350
+ // Blind copies alone are still an audience: a post for them and nobody
351
+ // else is direct, never public.
352
+ const blind = arr(activity.bto ?? object?.bto).length + arr(activity.bcc ?? object?.bcc).length;
353
+ if (!to.length && !cc.length) return blind ? 'direct' : 'public';
338
354
  if (to.includes(wire.PUBLIC)) return 'public';
339
355
  if (cc.includes(wire.PUBLIC)) return 'unlisted';
340
- if (to.includes(this.urls.followers)) return 'private';
356
+ // Followers named anywhere — "to alice, cc my followers" included — is a
357
+ // followers-only post that also reaches whoever else is named.
358
+ if (to.includes(this.urls.followers) || cc.includes(this.urls.followers)) return 'private';
341
359
  return 'direct';
342
360
  }
343
361
 
344
- async dispatch(activity, { slug = null, raw = null } = {}) {
362
+ // `serial` and `at` name what this activity will make: the outbox door
363
+ // chose them and told the client, so they are used as given. A client
364
+ // posting here directly gets fresh ones.
365
+ async dispatch(activity, { slug = null, raw = null, serial = Date.now(), at = new Date().toISOString() } = {}) {
345
366
  const reply = (status, body, headers = {}) => ({ status, body, headers });
346
367
  if (!activity || typeof activity !== 'object' || Array.isArray(activity) || !activity.type) {
347
368
  return reply(400, { error: 'a typed ActivityStreams object is required' });
@@ -349,10 +370,12 @@ export class C2S {
349
370
  // A bare object arrives without an activity around it; the server supplies
350
371
  // the Create (§6.2.1), carrying the object's own addressing up onto it.
351
372
  if (!ACTIVITY_TYPES.has(activity.type)) {
352
- activity = { type: 'Create', object: activity, to: activity.to, cc: activity.cc };
373
+ // Every audience field is copied up onto the Create (§6.2.1).
374
+ const { to, cc, bto, bcc, audience } = activity;
375
+ activity = { type: 'Create', object: activity, to, cc, bto, bcc, audience };
353
376
  }
354
377
  try {
355
- return await this._dispatch(activity, { slug, raw, reply });
378
+ return await this._dispatch(activity, { slug, raw, reply, serial, at });
356
379
  } catch (e) {
357
380
  this.log(`c2s ${activity?.type}: ${e.message}`);
358
381
  return reply(422, { error: e.message || String(e) });
@@ -366,11 +389,23 @@ export class C2S {
366
389
  const pick = (...fields) => [...new Set(fields.flatMap((f) => arr(activity[f] ?? object?.[f]).map(idOf)))]
367
390
  .filter((a) => typeof a === 'string' && /^https?:\/\//u.test(a)
368
391
  && a !== wire.PUBLIC && a !== this.urls.followers && a !== this.urls.actor);
369
- return { also: pick('to', 'cc'), deliverTo: pick('bto', 'bcc') };
392
+ // `audience` is who the post is for, a group most often: delivered to and
393
+ // named like to and cc (§7.1).
394
+ return { also: pick('to', 'cc', 'audience'), deliverTo: pick('bto', 'bcc') };
370
395
  }
371
396
 
372
- async _dispatch(activity, { slug, raw, reply }) {
397
+ async _dispatch(activity, { slug, raw, reply, serial, at }) {
373
398
  const agent = this.agent;
399
+ // §6: every post to the outbox answers 201 with the new activity's id.
400
+ const made = (id, body = {}) => reply(201, { id, ...body }, { location: id });
401
+ // What is never sent anywhere — a person's block, a pin — is still a
402
+ // message this actor produced, so the owner's outbox records it here.
403
+ const kept = (type, extra) => {
404
+ const id = type === 'Undo' ? wire.undoActivityId(this.urls, serial) : `${this.urls.actor}#${type.toLowerCase()}-${serial}`;
405
+ const act = { id, type, actor: this.urls.actor, published: at, ...extra };
406
+ agent.publisher.recordOwn?.(act);
407
+ return act.id;
408
+ };
374
409
  const object = typeof activity.object === 'object' && activity.object !== null
375
410
  ? activity.object : null;
376
411
  const objectId = idOf(activity.object);
@@ -411,6 +446,7 @@ export class C2S {
411
446
  visibility,
412
447
  spoilerText: object.summary || null,
413
448
  sensitive: object.sensitive === true,
449
+ slug,
414
450
  });
415
451
  return reply(201,
416
452
  { id: wire.createActivityId(question.id), object: question.id },
@@ -445,19 +481,32 @@ export class C2S {
445
481
  if (s.actor !== this.urls.actor || s.kind !== 'post') {
446
482
  return reply(403, { error: 'not your note' });
447
483
  }
448
- const text = String(object?.source?.content ?? object?.content ?? '');
449
- if (!text.trim()) return reply(422, { error: 'the edit has no content' });
450
- const attachments = object?.attachment !== undefined
484
+ // §6.3: a client's Update changes only what it sends, and a field sent
485
+ // as null is removed. What was sent is read from the bytes, because a
486
+ // JSON-LD reading drops the nulls that mean "remove this".
487
+ const sent = raw?.type === 'Update' && raw.object && typeof raw.object === 'object' ? raw.object : (object || {});
488
+ const has = (k) => Object.prototype.hasOwnProperty.call(sent, k);
489
+ const stored = await Promise.resolve().then(() => podNotes.read(agent.remote, s.noteId)).catch(() => null);
490
+ if (stored?.type && stored.type !== 'Note') {
491
+ await agent.publisher.updateObject(s, sent, { updated: at });
492
+ return made(wire.updateActivityId(s.noteId, at), { object: s.noteId });
493
+ }
494
+ const text = has('content') || sent.source?.content !== undefined
495
+ ? String(object?.source?.content ?? object?.content ?? '') : String(s.text ?? '');
496
+ if (!text.trim()) return reply(422, { error: 'the edit leaves the note with no content' });
497
+ const attachments = has('attachment')
451
498
  ? arr(object.attachment).map((a) => ({
452
499
  url: a?.url, mediaType: a?.mediaType,
453
500
  ...(a?.name ? { description: a.name } : {}),
454
501
  })).filter((a) => a.url)
455
502
  : null;
456
503
  await agent.publisher.updateNote(s, {
457
- content: text, spoilerText: object?.summary || null,
458
- sensitive: object?.sensitive === undefined ? null : object.sensitive === true, attachments,
504
+ content: text,
505
+ spoilerText: has('summary') ? (object?.summary || null) : (s.spoiler || null),
506
+ sensitive: has('sensitive') ? object?.sensitive === true : null, attachments,
507
+ updated: at,
459
508
  });
460
- return reply(200, { ok: true, object: s.noteId });
509
+ return made(wire.updateActivityId(s.noteId, at), { object: s.noteId });
461
510
  }
462
511
 
463
512
  case 'Delete': {
@@ -471,7 +520,7 @@ export class C2S {
471
520
  }
472
521
  const r = await social.deleteNote(agent, s);
473
522
  if (!r.ok) return reply(502, { error: r.error, stillPublished: r.stillPublished });
474
- return reply(200, { ok: true });
523
+ return made(wire.deleteActivityId(s.noteId), { object: s.noteId });
475
524
  }
476
525
 
477
526
  case 'Follow': {
@@ -479,31 +528,27 @@ export class C2S {
479
528
  // An acct: form or bare handle resolves through WebFinger; an https
480
529
  // IRI is fetched directly.
481
530
  if (/^acct:|^@|^[^/@]+@[^/@]+$/.test(objectId) && !/^https?:/.test(objectId)) {
482
- const r = await social.followHandle(agent, objectId.replace(/^acct:/, ''));
531
+ const r = await social.followHandle(agent, objectId.replace(/^acct:/, ''), { serial });
483
532
  const rec = this.store.getContacts().following.find((f) => f.actor === r.actor);
484
- return reply(201, { id: rec?.followActivity?.id, object: r.actor },
485
- rec?.followActivity?.id ? { location: rec.followActivity.id } : {});
533
+ return made(rec?.followActivity?.id, { object: r.actor });
486
534
  }
487
- const doc = await social.followActor(agent, objectId);
535
+ const doc = await social.followActor(agent, objectId, { serial });
488
536
  const rec = this.store.getContacts().following.find((f) => f.actor === doc.id);
489
- return reply(201, { id: rec?.followActivity?.id, object: doc.id },
490
- rec?.followActivity?.id ? { location: rec.followActivity.id } : {});
537
+ return made(rec?.followActivity?.id, { object: doc.id });
491
538
  }
492
539
 
493
540
  case 'Like': {
494
541
  const s = this.byIri(objectId);
495
542
  if (!s) return reply(422, { error: 'that note is not held here — like what the timeline holds' });
496
- const updated = await social.favourite(agent, s);
497
- return reply(201, { id: updated.likeActivity?.id, object: s.noteId },
498
- updated.likeActivity?.id ? { location: updated.likeActivity.id } : {});
543
+ const updated = await social.favourite(agent, s, { serial });
544
+ return made(updated.likeActivity?.id, { object: s.noteId });
499
545
  }
500
546
 
501
547
  case 'Announce': {
502
548
  const s = this.byIri(objectId);
503
549
  if (!s) return reply(422, { error: 'that note is not held here — boost what the timeline holds' });
504
- const updated = await social.reblog(agent, s);
505
- return reply(201, { id: updated.announceActivity?.id, object: s.noteId },
506
- updated.announceActivity?.id ? { location: updated.announceActivity.id } : {});
550
+ const updated = await social.reblog(agent, s, { serial });
551
+ return made(updated.announceActivity?.id, { object: s.noteId });
507
552
  }
508
553
 
509
554
  case 'Undo': {
@@ -511,39 +556,47 @@ export class C2S {
511
556
  // sends one, by its type+object when it re-states it instead.
512
557
  const inner = object;
513
558
  const innerId = idOf(activity.object);
559
+ const undone = wire.undoActivityId(this.urls, serial);
514
560
  if (inner?.type === 'Block') {
515
561
  const target = idOf(inner.object);
516
562
  if (!target) return reply(400, { error: 'unblock whom?' });
517
- await social.unblockActor(agent, target);
518
- return reply(200, { ok: true, object: target });
563
+ await social.unblockActor(agent, target, { serial });
564
+ // A group's unban was sent, and recorded as it went; a person's is not.
565
+ if (this.store.getConfig()?.kind !== 'group') {
566
+ kept('Undo', { object: { type: 'Block', actor: this.urls.actor, object: target } });
567
+ }
568
+ return made(undone, { object: target });
519
569
  }
520
570
  const statuses = this.store.getStatuses();
521
571
  let s = innerId ? statuses.find((x) => x.likeActivity?.id === innerId) : null;
522
572
  if (!s && inner?.type === 'Like') s = this.byIri(idOf(inner.object));
523
573
  if (s?.favourited) {
524
- const updated = await social.unfavourite(agent, s);
525
- return reply(200, { ok: true, object: updated.noteId });
574
+ const updated = await social.unfavourite(agent, s, { serial });
575
+ return made(undone, { object: updated.noteId });
526
576
  }
527
577
  s = innerId ? statuses.find((x) => x.announceActivity?.id === innerId) : null;
528
578
  if (!s && inner?.type === 'Announce') s = this.byIri(idOf(inner.object));
529
579
  if (s?.reblogged) {
530
- const updated = await social.unreblog(agent, s);
531
- return reply(200, { ok: true, object: updated.noteId });
580
+ const updated = await social.unreblog(agent, s, { serial });
581
+ return made(undone, { object: updated.noteId });
532
582
  }
533
583
  const following = this.store.getContacts().following;
534
584
  const rec = following.find((f) => f.followActivity?.id === innerId)
535
585
  || (inner?.type === 'Follow' ? following.find((f) => f.actor === idOf(inner.object)) : null);
536
586
  if (rec) {
537
- await social.unfollowActor(agent, rec.actor);
538
- return reply(200, { ok: true, object: rec.actor });
587
+ await social.unfollowActor(agent, rec.actor, { serial });
588
+ return made(undone, { object: rec.actor });
539
589
  }
540
590
  return reply(422, { error: 'nothing here matches what that Undo names' });
541
591
  }
542
592
 
543
593
  case 'Block': {
544
594
  if (!objectId) return reply(400, { error: 'block whom? object must name an actor' });
545
- await social.blockActor(agent, objectId);
546
- return reply(200, { ok: true, object: objectId });
595
+ await social.blockActor(agent, objectId, { serial });
596
+ // A group's ban was sent to its members; a person's never goes anywhere.
597
+ const id = this.store.getConfig()?.kind === 'group'
598
+ ? `${this.urls.actor}#block-${serial}` : kept('Block', { object: objectId });
599
+ return made(id, { object: objectId });
547
600
  }
548
601
 
549
602
  case 'Add':
@@ -556,7 +609,8 @@ export class C2S {
556
609
  const s = this.byIri(objectId);
557
610
  if (!s) return reply(404, { error: 'no such note here' });
558
611
  const updated = await social.pinStatus(agent, s, activity.type === 'Add');
559
- return reply(200, { ok: true, object: updated.noteId, pinned: !!updated.pinned });
612
+ return made(kept(activity.type, { object: updated.noteId, target: this.urls.featured }),
613
+ { object: updated.noteId, pinned: !!updated.pinned });
560
614
  }
561
615
 
562
616
  case 'Accept':
@@ -566,10 +620,10 @@ export class C2S {
566
620
  const requester = object?.actor ? idOf(object.actor) : objectId;
567
621
  if (!requester) return reply(400, { error: 'whose request? object must name the Follow or its actor' });
568
622
  const r = activity.type === 'Accept'
569
- ? await social.admitRequest(agent, requester).catch((e) => ({ error: e.message }))
570
- : await social.refuseRequest(agent, requester).catch((e) => ({ error: e.message }));
623
+ ? await social.admitRequest(agent, requester, { serial }).catch((e) => ({ error: e.message }))
624
+ : await social.refuseRequest(agent, requester, { serial }).catch((e) => ({ error: e.message }));
571
625
  if (r.error) return reply(404, { error: r.error });
572
- return reply(200, { ok: true, object: requester });
626
+ return made(`${this.urls.actor}#${activity.type.toLowerCase()}-${serial}`, { object: requester });
573
627
  }
574
628
 
575
629
  case 'Move':
@@ -255,6 +255,7 @@ export async function handle(api, ctx) {
255
255
  if (req.method === 'POST' && !b.domains.includes(domain)) b.domains.push(domain);
256
256
  if (req.method === 'DELETE') b.domains = b.domains.filter(d => d !== domain);
257
257
  api.store.setBlocklist(b);
258
+ Promise.resolve(api.agent?.publisher?.publishCollections?.({ blocked: true })).catch(() => {});
258
259
  return send(200, {});
259
260
  }
260
261
 
@@ -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
+ }