fedipod 1.45.0 → 1.48.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 (54) hide show
  1. package/README.md +11 -16
  2. package/architecture.md +6 -3
  3. package/gateway.md +51 -23
  4. package/gui.md +3 -2
  5. package/lib/client/c2s.mjs +2 -1
  6. package/lib/client/masto/index.mjs +5 -4
  7. package/lib/client/masto/render.mjs +4 -1
  8. package/lib/core/intake/activities.mjs +8 -9
  9. package/lib/core/intake/counts.mjs +143 -0
  10. package/lib/core/intake/index.mjs +4 -0
  11. package/lib/core/publisher/notes.mjs +4 -1
  12. package/lib/core/social.mjs +2 -0
  13. package/lib/core/storage.mjs +54 -5
  14. package/lib/core/wire.mjs +16 -5
  15. package/lib/gateway/account-agent.mjs +20 -4
  16. package/lib/gateway/copy.mjs +95 -5
  17. package/lib/gateway/front-core.mjs +4 -4
  18. package/lib/gateway/held-mail.mjs +11 -4
  19. package/lib/gateway/keeper.mjs +9 -6
  20. package/lib/gateway/masto-gateway.mjs +140 -40
  21. package/lib/gateway/private-read.mjs +10 -3
  22. package/lib/gateway/quiet.mjs +4 -3
  23. package/lib/gateway/state-api.mjs +73 -18
  24. package/lib/pod/notes.mjs +25 -0
  25. package/lib/server/embed.mjs +1 -1
  26. package/lib/session/README.md +2 -135
  27. package/lib/surface/routes/owner.mjs +2 -2
  28. package/lib/surface/surface.mjs +4 -3
  29. package/package.json +5 -3
  30. package/scripts/check-netlify-bundles.mjs +0 -4
  31. package/scripts/check-pod-calls.mjs +5 -108
  32. package/scripts/check-sizes.mjs +6 -3
  33. package/scripts/dependents.mjs +76 -0
  34. package/scripts/stage-site.mjs +10 -8
  35. package/scripts/start.mjs +2 -2
  36. package/scripts/test-dependents.mjs +37 -0
  37. package/web/admin/gateway.js +8 -24
  38. package/web/admin/index.html +0 -16
  39. package/web/admin/record.js +1 -2
  40. package/web/admin/upkeep.js +1 -5
  41. package/web/app/admin-facade.mjs +1 -1
  42. package/web/app/agent.mjs +27 -10
  43. package/web/app/copy-mode.mjs +98 -13
  44. package/web/app/dist/boot.js +68 -57
  45. package/web/app/dist/boot.js.map +4 -4
  46. package/web/app/dist/sw.js +583 -283
  47. package/web/app/dist/sw.js.map +4 -4
  48. package/web/app/forum-support.mjs +2 -2
  49. package/web/app/oidc-session.mjs +2 -2
  50. package/lib/session/demo.html +0 -94
  51. package/lib/session/fedi-account.mjs +0 -417
  52. package/lib/session/fedi-login.mjs +0 -123
  53. package/lib/session/oidc-session.mjs +0 -233
  54. package/lib/session/package.json +0 -49
package/README.md CHANGED
@@ -5,8 +5,9 @@
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
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
+ keeps your account running, it works from a copy of them, and every fifteen
9
+ minutes it writes that copy to your pod and deletes it; your signing key never
10
+ leaves your pod.
10
11
 
11
12
  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.
12
13
 
@@ -109,14 +110,6 @@ there it takes over; the first drops back to reading.
109
110
 
110
111
  ## What the browser version does not do
111
112
 
112
- - **Scheduled posts, if you stop fedipod.net keeping your account running.**
113
- It does by default, and publishes them while FediPod is closed. Turned off
114
- on the manage page, nothing runs between now and the time you picked, so
115
- the composer refuses one rather than dropping it later.
116
- - **Notifications, and other clients, if you stop fedipod.net keeping your
117
- account running.** While it does (the default), any Mastodon app, phone or
118
- desktop, can use your account with `fedipod.net` as its server, and an app
119
- that offers notifications gets them on your phone with everything closed.
120
113
  - **Live updates.** The client refreshes by polling.
121
114
  - **Hosting a group.** Joining one works.
122
115
 
@@ -128,9 +121,11 @@ Everything you publish and everything you read is stored on your pod. Your
128
121
  signing key is stored there too, in a container only you can read through
129
122
  your pod's login.
130
123
  While fedipod.net keeps your account running, its own pod identity can read
131
- that container too, so it can act for you while FediPod is closed, and it
132
- works from a copy of your account's data that it writes back to your pod every
133
- fifteen minutes. Your key stays on your pod only.
124
+ that container too, so it can act for you while FediPod is closed. It works
125
+ from a copy of your account's data, and every fifteen minutes it writes that
126
+ copy to your pod and deletes it, so none of your data stays at fedipod.net
127
+ longer than that. The next time anything needs your account, the copy is made
128
+ again from your pod. Your key stays on your pod only.
134
129
  It all lives in the `fedipod` container you chose at sign-up. Your pod's
135
130
  public type index records it, as an ActivityStreams actor, which is how
136
131
  FediPod and other Solid apps find your account again.
@@ -142,11 +137,11 @@ not change.
142
137
 
143
138
  ## Other ways to use FediPod
144
139
 
145
- - [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.
146
- - [FediPod Server](packages/fedipod-server/README.md): a full ActivityPub
140
+ - [fediverse-session](https://github.com/jeff-zucker/fediverse-session) - an ESM library that supports both Solid-based and regular Fediverse accounts with methods to login, reply, boost, etc.
141
+ - [FediPod Server](https://github.com/jeff-zucker/fedipod-server): a full ActivityPub
147
142
  server as a Community Solid Server component, giving every pod on the server
148
143
  the option of a Fediverse account.
149
- - [FediPod BB](packages/fedipod-bb/README.md) - A Lemmy-like bulletin board forum with the structure stored on a group pod and users' data stored on their own pod or on their Fediverse server if they don't have a pod.
144
+ - [FediPod BB](https://github.com/jeff-zucker/fedipod-bb) - A Lemmy-like bulletin board forum with the structure stored on a group pod and users' data stored on their own pod or on their Fediverse server if they don't have a pod.
150
145
  - [FediPod DeviceAgent](device-agent.md) a single-user AP server that lives partly in a gateway, partly on a local device, with all data stored on your pod.
151
146
  - [FediPod Groups](groups.md): hosting a discussion group of Fediverse and Bluesky users from a pod.
152
147
  - [FediPod Gateway](gateway.md): A thin, always-on door running on Netlify or any small host that provides a middleman between a pod and the single-user agents. (only needed with `BrowserAgent` and `DeviceAgent`)
package/architecture.md CHANGED
@@ -21,10 +21,13 @@ account's agent whenever FediPod is closed, under a pod identity of its own
21
21
  that the owner's rules name, reading the signing key from the pod when it
22
22
  needs it. The account's state documents live in a working copy at the
23
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).
24
+ work from. Every fifteen minutes the gateway writes the copy to the pod and
25
+ deletes it, and makes it again from the pod when something next needs it (the
26
+ hold). A gateway's admin may turn the hold off: then there is no copy, and
27
+ everything works on the pod, the browser and the gateway taking turns by the
28
+ pod's lease. See [the gateway](gateway.md).
26
29
 
27
- [FediPod Server](packages/fedipod-server/README.md) puts the agent inside a
30
+ [FediPod Server](https://github.com/jeff-zucker/fedipod-server) puts the agent inside a
28
31
  Community Solid Server, so anyone with a pod on that server can opt in to a
29
32
  Fediverse account fed by the server itself.
30
33
 
package/gateway.md CHANGED
@@ -15,7 +15,7 @@ yours, which your agent then signs and sends. It still holds no key, but what yo
15
15
  trust it with grows by that much. A browser account is also kept running by
16
16
  the gateway (below). That gives the gateway your key while it works, so it can
17
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. For
18
+ held at the gateway while it works, for at most fifteen minutes at a time. For
19
19
  a kept account the gateway also answers a follower's server asking for one
20
20
  of your followers-only posts, and the named person's server asking for a
21
21
  direct one, when that server signs its request; nobody else gets them.
@@ -174,28 +174,57 @@ closed, fedipod.net does two things for it.
174
174
  your pod one by one, and reach your pod in batches of up to a hundred: when you
175
175
  open FediPod, or every fifteen minutes otherwise. While FediPod is open, mail
176
176
  goes straight to your pod as before. A DeviceAgent's mail always goes straight
177
- to its pod.
177
+ to its pod. On a gateway whose admin has turned the hold off (below), mail
178
+ always goes straight to your pod.
178
179
 
179
- **The gateway keeps your account running.** Unless you turn it off, the
180
- gateway accepts your follows, tries again what failed to go out, and publishes
181
- the posts you scheduled, all while FediPod is closed. Other mail waits on your
180
+ **The gateway keeps your account running.** It accepts your follows, tries
181
+ again what failed to go out, and publishes the posts you scheduled, all while FediPod is closed. Other mail waits on your
182
182
  pod for FediPod to open, as before. It works under its own pod identity, which your app
183
183
  names in the access rules on your FediPod folder. It reads your signing key
184
184
  from your pod when it needs it, so it can post as you, and keeps no copy of the
185
- key. **Stop keeping it running** on the manage page takes it out of the rules.
186
- If fedipod.net ever changes its own pod identity, your FediPod moves your
185
+ key. If fedipod.net ever changes its own pod identity, your FediPod moves your
187
186
  account over the next time you open it, with nothing for you to do.
188
187
  Scheduling a post is offered only while this is on, because otherwise nothing
189
188
  would be running when the time came.
190
189
 
191
- **Your account's working data is kept at the gateway.** While the gateway
192
- keeps your account running, your timeline, notifications, followers, settings
193
- and the rest of your FediPod state are kept at fedipod.net, and FediPod in your
194
- browser, the gateway and any app you use all work from that one copy, so they
195
- never disagree. fedipod.net writes what changed to your pod every fifteen
196
- minutes, and everything at once when you stop it keeping your account running,
197
- move your address or close it. Your signing key and the passwords of accounts
198
- you connected elsewhere are never in the copy; they stay on your pod only.
190
+ **Your account's working data is held at the gateway for at most fifteen
191
+ minutes.** While the gateway keeps your account running, FediPod in your
192
+ browser, the gateway and any app you use all work from one copy of your
193
+ timeline, notifications, followers, settings and the rest of your FediPod
194
+ state, made at fedipod.net from your pod, so they never disagree. Every
195
+ fifteen minutes, whatever is happening, fedipod.net writes that copy to your
196
+ pod and deletes it. The next time anything needs your account (FediPod, an
197
+ app, or the gateway's own work while you're away), the copy is made again from
198
+ your pod. You don't notice, except that an app's first check after a deletion
199
+ can take a few seconds longer. When your address moves or is closed, the copy
200
+ goes to your pod at once. Your signing key and the passwords of accounts you
201
+ connected elsewhere are never in the copy; they stay on your pod only.
202
+
203
+ ## The hold, for a gateway's admin
204
+
205
+ Whoever runs a gateway decides whether it holds copies of accounts at all,
206
+ with one setting, `FEDIPOD_HOLD` ([netlify/README.md](netlify/README.md)).
207
+
208
+ - **On**, the default and fedipod.net's choice: as above. Mail waits at the
209
+ gateway for up to fifteen minutes, and each account's copy is written to its
210
+ pod and deleted every fifteen minutes.
211
+ - **Off**: the gateway keeps no copy of anyone's account and holds no mail.
212
+ Mail goes straight to the pod as it arrives, an app's every check reads the
213
+ pod, and FediPod in the browser works on the pod, taking turns with the
214
+ gateway when an app acts. None of anyone's mail, timeline or direct messages
215
+ is ever stored at the gateway. It costs more: roughly two to four times the
216
+ gateway's hosting per account, and about ten times the requests on each pod.
217
+ Apps are slower, and on a pod provider that limits traffic they can show
218
+ errors at busy times.
219
+
220
+ A change takes effect within fifteen minutes. Off to on: an account's copy is
221
+ made the next time anything uses it. On to off: every copy is written to its
222
+ pod and deleted, at the next round, or by FediPod itself within five minutes
223
+ where it is open, so nothing it is writing is lost.
224
+
225
+ Either way the gateway keeps app sign-ins (as fingerprints), phone-notification
226
+ sign-ups, each account's record (with its door secret), the time an account's
227
+ next scheduled post is due, and public copies of posts for up to an hour.
199
228
 
200
229
  ## Using any Mastodon app
201
230
 
@@ -204,7 +233,8 @@ elk.zone, Ivory, Tusky, Phanpy and the rest. In the app, give `fedipod.net` as
204
233
  your server. The app sends you to a fedipod.net page that asks for your
205
234
  Fediverse address or your WebID and signs you in at your own pod; no password
206
235
  is typed on fedipod.net. The app then reads your timeline and notifications from your
207
- account's copy at the gateway, and posts, boosts, likes and follows as you.
236
+ account's copy at the gateway (from your pod, on a gateway with the hold off),
237
+ and posts, boosts, likes and follows as you.
208
238
 
209
239
  An app checks for new posts every minute or so while it is open; there are no
210
240
  live updates. Accounts are made on the fedipod.net front page, with a pod, not
@@ -226,13 +256,11 @@ about each browser account — when its owner last signed in or posted, and
226
256
  how much content has arrived since — and acts on them.
227
257
 
228
258
  **Paused.** After about 5,000 posts, replies, likes, boosts and edits since
229
- you were last here, or when you say so on the manage page, the door accepts
230
- content and discards it. Follows, unfollows, account moves, deletions and
231
- blocks still reach your pod. Signing in ends an automatic pause by itself; a
232
- pause you set lasts until you lift it.
259
+ you were last here, the door accepts content and discards it. Follows,
260
+ unfollows, account moves, deletions and blocks still reach your pod. Signing
261
+ in ends the pause.
233
262
 
234
- **Closed.** After six months without a sign-in, or when you say so on the
235
- manage page, the address is closed for good: its handle, its actor and its
263
+ **Closed.** After six months without a sign-in, the address is closed for good: its handle, its actor and its
236
264
  door answer 410 Gone, other servers drop the account the next time they look,
237
265
  and nobody can take the name. Nothing on your pod is touched. An address that
238
266
  had already moved to another gateway keeps answering as moved.
@@ -280,7 +308,7 @@ Two ways are ready to use:
280
308
  [netlify/README.md](netlify/README.md) for the deployment specifics.
281
309
  - **On any box of your own**, with your own adapter around the same core.
282
310
  - **Inside a Community Solid Server**, as the same door run in-process by the
283
- CSS component — see [packages/fedipod-server](packages/fedipod-server/README.md).
311
+ CSS component — see [fedipod-server](https://github.com/jeff-zucker/fedipod-server).
284
312
 
285
313
  ### Offering accounts to other people
286
314
 
package/gui.md CHANGED
@@ -97,8 +97,9 @@ is marked **sign in again** rather than quietly dropped.
97
97
 
98
98
  ## Handling follow requests
99
99
 
100
- New followers appear under **Follow requests** with **Accept** and **Refuse**
101
- beside them; nothing is accepted without you.
100
+ New followers are accepted automatically. Set **new followers** to *wait for
101
+ approval* and they appear under **Follow requests** instead, with **Accept**
102
+ and **Refuse** beside them; nothing is accepted without you.
102
103
  They show in any Mastodon client as well, now that the client API serves the
103
104
  queue and takes both answers.
104
105
  Groups are different — joining
@@ -260,7 +260,8 @@ export class C2S {
260
260
  // A viewer (another agent holds the lease) may not act — but the user
261
261
  // acting HERE outranks an idle active agent elsewhere, exactly as on the
262
262
  // facade: a write attempt claims the lease, and only a failed claim 503s.
263
- if (this.agent.viewer) {
263
+ // So does an agent whose state its gateway acts on too (sharesState).
264
+ if (this.agent.viewer || this.agent.sharesState) {
264
265
  const took = await this.agent.requestTakeover?.();
265
266
  if (!took) return this.send(res, 503, { error: 'another agent is active for this pod — takeover failed, try again' });
266
267
  }
@@ -149,10 +149,11 @@ 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
- // 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') {
152
+ // An agent whose state its gateway acts on too — its copy there, or with
153
+ // the gateway's hold off, its pod (the browser build, web/app/copy-mode.mjs)
154
+ // — asks every time, since an app at the gateway may have taken the lease
155
+ // without it hearing yet.
156
+ if ((this.agent.viewer || this.agent.sharesState) && req.method !== 'GET' && req.method !== 'HEAD') {
156
157
  const took = await this.agent.requestTakeover?.();
157
158
  if (!took) return send(503, { error: 'another agent is active for this pod — takeover failed, try again' });
158
159
  }
@@ -4,6 +4,7 @@
4
4
  // the three actions that answer as a connected account rather than the pod
5
5
  // actor (a Bluesky reply, a connected-account reply, a like or boost there).
6
6
 
7
+ import { totalsFor } from '../../core/intake/counts.mjs';
7
8
  import * as social from '../../core/social.mjs';
8
9
  import { sanitizeHtml, followsNeedApproval, publicHandle } from '../../core/wire.mjs';
9
10
  import { profileUrl, postUrl } from '../../connections/bskyfeed.mjs';
@@ -265,6 +266,8 @@ export function quoteApproval(api, s) {
265
266
 
266
267
  export function status(api, s, { all, depth = 0 } = {}) {
267
268
  const replies = (all || api.store.getStatuses()).filter(x => x.inReplyTo === s.noteId).length;
269
+ // The owner's own posts: what others did to them. Zero for anyone else's.
270
+ const counted = totalsFor(api.store, s.noteId);
268
271
  return {
269
272
  id: api.store.idFor(s.noteId),
270
273
  created_at: s.published || new Date().toISOString(),
@@ -274,7 +277,7 @@ export function status(api, s, { all, depth = 0 } = {}) {
274
277
  visibility: s.visibility || 'public', language: null,
275
278
  edited_at: s.editedAt || null,
276
279
  uri: s.noteId, url: s.link || s.noteId,
277
- replies_count: replies, reblogs_count: 0, favourites_count: 0,
280
+ replies_count: replies, reblogs_count: counted.shares, favourites_count: counted.likes,
278
281
  // True when ANY of the owner's accounts holds it. The flag is really
279
282
  // what the next tap will do: an empty star on a post one account has
280
283
  // already liked invites a second outward like from a second identity.
@@ -2,6 +2,7 @@
2
2
  // from Intake.handle() after the sender passed the door checks, and returns a
3
3
  // rejection reason or nothing.
4
4
 
5
+ import { withdrawReaction } from './counts.mjs';
5
6
  import { dropFollower } from '../store.mjs';
6
7
  import { emojisOf, pollOf, isContentType, trimActivity, reactionOf, quoteOf, quotePolicyOf, authorOf } from './activity.mjs';
7
8
 
@@ -40,20 +41,17 @@ export async function onFollow(intake, activity, actor, { trusted = false } = {}
40
41
  // anyone at all, and we would sign an Accept, deliver it to that person,
41
42
  // and send them everything published from then on.
42
43
  //
43
- // Until deliveries terminate somewhere their signature survives, a follow
44
- // we cannot verify is a REQUEST, waiting in the same queue a gated group
45
- // uses. The requester's client shows "Requested", which is the ordinary
46
- // locked-account state that manuallyApprovesFollowers tells it to expect.
47
- // `autoAcceptFollows: true` in config restores the old behaviour.
48
- // A GROUP is left alone: `approveJoins: false` is its operator saying, in
49
- // as many words, that anyone may join, and mute/eject are the remedy there.
50
- // A person has no such setting, so this is their default.
44
+ // A person who set `autoAcceptFollows: false` sees such a follow as a
45
+ // REQUEST, waiting in the same queue a gated group uses; the requester's
46
+ // client shows "Requested", the locked-account state manuallyApprovesFollowers
47
+ // tells it to expect. Otherwise it is accepted, as a group's is:
48
+ // `approveJoins: false` is its operator saying anyone may join.
51
49
  //
52
50
  // A gateway-verified Follow (trust mode) is no longer unverifiable — the
53
51
  // door proved the sender — so it does not need the OK that unverifiability
54
52
  // alone demanded. An explicit `approveJoins` still holds: verified or not,
55
53
  // the operator asked to see joins.
56
- const unverifiedNeedsOk = intake.config.kind !== 'group' && !intake.config.autoAcceptFollows && !trusted;
54
+ const unverifiedNeedsOk = intake.config.kind !== 'group' && intake.config.autoAcceptFollows === false && !trusted;
57
55
  const mustApprove = intake.config.approveJoins || unverifiedNeedsOk;
58
56
  if (mustApprove && !existing) {
59
57
  const reqs = intake.store.getRequests();
@@ -319,6 +317,7 @@ export async function onUndoReaction(intake, inner, actor, { trusted = false } =
319
317
  const type = inner.type === 'Like' ? 'favourite' : 'reblog';
320
318
  const gone = intake.store.removeNotifications(n => n.type === type && n.actor === actor && n.noteId === objectId);
321
319
  if (gone) intake.log(`${inner.type} withdrawn by ${actor} on ${objectId}`);
320
+ withdrawReaction(intake.store, inner.type, objectId, actor);
322
321
  return;
323
322
  }
324
323
  if (inner.type !== 'Announce') return;
@@ -0,0 +1,143 @@
1
+ // counts.mjs — how many liked and boosted each of the owner's posts, and the
2
+ // totals on the post itself (ActivityPub §5.7, §5.8), so a server showing the
3
+ // post shows its real numbers instead of none.
4
+ //
5
+ // Who liked is kept here, in state, only to tell a repeat from a new one and
6
+ // to take back the right one on an Undo; the post says the total and names
7
+ // nobody. Past MAX_NAMED on one post the total still grows, but a repeat by
8
+ // someone past the cap can no longer be told apart.
9
+ //
10
+ // The first sweep that finds no totals kept starts them from the account's
11
+ // notifications, which hold its recent likes and boosts; older ones are gone.
12
+ //
13
+ // The post is rewritten once per sweep for every post whose totals changed,
14
+ // and only over the version it was read at: an edit or a deletion made in
15
+ // between wins, and the totals are written on a later sweep. The posts left
16
+ // to rewrite are kept in state, so a restart does not lose them.
17
+
18
+ import * as podNotes from '../../pod/notes.mjs';
19
+ import * as wire from '../wire.mjs';
20
+
21
+ export const MAX_NAMED = 1000;
22
+ export const MAX_POSTS = 2000;
23
+ export const REWRITES_PER_SWEEP = 20;
24
+ const COUNTS = 'counts.json';
25
+ const KIND = { Like: 'likes', Announce: 'shares' };
26
+ const MORE = { likes: 'moreLikes', shares: 'moreShares' };
27
+
28
+ const load = (store) => {
29
+ const st = typeof store?.read === 'function' ? store.read(COUNTS, null) : null;
30
+ return st && typeof st === 'object' && st.posts && typeof st.posts === 'object'
31
+ ? { posts: st.posts, dirty: Array.isArray(st.dirty) ? st.dirty : [] }
32
+ : { posts: {}, dirty: [] };
33
+ };
34
+
35
+ const totals = (p) => ({
36
+ likes: (p?.likes?.length || 0) + (p?.moreLikes || 0),
37
+ shares: (p?.shares?.length || 0) + (p?.moreShares || 0),
38
+ });
39
+
40
+ /** The totals for one post. Zero for a post nobody liked or boosted. */
41
+ export function totalsFor(store, noteId) {
42
+ // Read without a copy: the Mastodon API asks this for every post it lists.
43
+ const st = store?.cache?.get?.(COUNTS) ?? store?.read?.(COUNTS, null);
44
+ return totals(st?.posts?.[noteId]);
45
+ }
46
+
47
+ function save(store, st, noteId) {
48
+ if (typeof store?.write !== 'function') return;
49
+ if (noteId && !st.dirty.includes(noteId)) st.dirty.push(noteId);
50
+ const ids = Object.keys(st.posts);
51
+ if (ids.length > MAX_POSTS) {
52
+ // The posts touched longest ago go first.
53
+ ids.sort((a, b) => String(st.posts[a].at || '').localeCompare(String(st.posts[b].at || '')));
54
+ for (const id of ids.slice(0, ids.length - MAX_POSTS)) delete st.posts[id];
55
+ st.dirty = st.dirty.filter(id => st.posts[id]);
56
+ }
57
+ store.write(COUNTS, st);
58
+ }
59
+
60
+ /** A Like or an Announce of one of the owner's posts. True when a total changed. */
61
+ export function recordReaction(store, type, noteId, actor) {
62
+ const kind = KIND[type];
63
+ if (!kind || !noteId || !actor) return false;
64
+ const st = load(store);
65
+ const p = st.posts[noteId] || { likes: [], shares: [] };
66
+ const named = p[kind] || [];
67
+ if (named.includes(actor)) return false;
68
+ if (named.length >= MAX_NAMED) p[MORE[kind]] = (p[MORE[kind]] || 0) + 1;
69
+ else p[kind] = [...named, actor];
70
+ p.at = new Date().toISOString();
71
+ st.posts[noteId] = p;
72
+ save(store, st, noteId);
73
+ return true;
74
+ }
75
+
76
+ /** An Undo of one. True when a total changed. */
77
+ export function withdrawReaction(store, type, noteId, actor) {
78
+ const kind = KIND[type];
79
+ if (!kind || !noteId || !actor) return false;
80
+ const st = load(store);
81
+ const p = st.posts[noteId];
82
+ if (!p) return false;
83
+ const named = p[kind] || [];
84
+ if (named.includes(actor)) p[kind] = named.filter(a => a !== actor);
85
+ else if (p[MORE[kind]] > 0) p[MORE[kind]] -= 1;
86
+ else return false;
87
+ p.at = new Date().toISOString();
88
+ save(store, st, noteId);
89
+ return true;
90
+ }
91
+
92
+ /** A deleted post keeps no totals. */
93
+ export function forgetCounts(store, noteId) {
94
+ const st = load(store);
95
+ if (!st.posts[noteId] && !st.dirty.includes(noteId)) return;
96
+ delete st.posts[noteId];
97
+ st.dirty = st.dirty.filter(id => id !== noteId);
98
+ save(store, st, null);
99
+ }
100
+
101
+ // Totals from before they were kept: the likes and boosts still in the
102
+ // notifications. Runs once, the first time there are no totals at all.
103
+ function seed(intake) {
104
+ const { store } = intake;
105
+ if (typeof store?.read !== 'function' || store.read(COUNTS, null)) return;
106
+ const notes = intake.urls?.notes;
107
+ const all = typeof store.getNotifications === 'function' ? store.getNotifications() : [];
108
+ for (const n of [...all].reverse()) { // oldest first, as they arrived
109
+ if (!notes || !String(n?.noteId || '').startsWith(notes)) continue;
110
+ if (n.type === 'favourite') recordReaction(store, 'Like', n.noteId, n.actor);
111
+ else if (n.type === 'reblog') recordReaction(store, 'Announce', n.noteId, n.actor);
112
+ }
113
+ if (!store.read(COUNTS, null)) store.write(COUNTS, { posts: {}, dirty: [] });
114
+ }
115
+
116
+ /**
117
+ * Write the changed totals onto the posts. Returns how many posts were
118
+ * rewritten. A post the pod no longer has, or has as a Tombstone, is dropped.
119
+ */
120
+ export async function publishCounts(intake) {
121
+ seed(intake);
122
+ const st = load(intake.store);
123
+ if (!st.dirty.length) return 0;
124
+ const left = [];
125
+ let wrote = 0;
126
+ for (const noteId of st.dirty.slice(0, REWRITES_PER_SWEEP)) {
127
+ try {
128
+ const { doc, version } = await podNotes.readVersioned(intake.remote, noteId);
129
+ if (!doc || typeof doc !== 'object' || !doc.id || doc.type === 'Tombstone') { delete st.posts[noteId]; continue; }
130
+ const t = totals(st.posts[noteId]);
131
+ const next = { ...doc, ...wire.reactionCounts(t.likes, t.shares) };
132
+ if (JSON.stringify([next.likes, next.shares]) === JSON.stringify([doc.likes, doc.shares])) continue;
133
+ if (await podNotes.writeIfUnchanged(intake.remote, doc.id, next, version)) wrote++;
134
+ else left.push(noteId); // changed under us: the next sweep reads it again
135
+ } catch (e) {
136
+ intake.log?.(`like and boost totals for ${noteId}: ${e.message}`);
137
+ left.push(noteId);
138
+ }
139
+ }
140
+ st.dirty = [...left, ...st.dirty.slice(REWRITES_PER_SWEEP)].filter(id => st.posts[id]);
141
+ intake.store.write(COUNTS, st);
142
+ return wrote;
143
+ }
@@ -29,6 +29,7 @@ import * as channel from './channel.mjs';
29
29
  import * as verify from './verify.mjs';
30
30
  import * as group from './group.mjs';
31
31
  import * as received from './received.mjs';
32
+ import * as counts from './counts.mjs';
32
33
  import * as activities from './activities.mjs';
33
34
  import * as notes from './notes.mjs';
34
35
  import { readLenient } from '../as2.mjs';
@@ -188,6 +189,8 @@ export class Intake {
188
189
  this._draining = this._drainOnce().finally(async () => {
189
190
  this._inSweep = false;
190
191
  await this._publishPending();
192
+ // Likes and boosts this sweep took, onto the posts: one write per post.
193
+ await counts.publishCounts(this).catch((e) => this.log(`like and boost totals: ${e.message}`));
191
194
  this.store.release?.();
192
195
  this._draining = null;
193
196
  if (this._drainAgain) {
@@ -767,6 +770,7 @@ export class Intake {
767
770
  type: activity.type === 'Like' ? 'favourite' : 'reblog', actor, noteId: objectId,
768
771
  ...(this.known(actor) ? {} : { unverified: true }),
769
772
  });
773
+ counts.recordReaction(this.store, activity.type, objectId, actor); // §5.7, §5.8
770
774
  return;
771
775
  }
772
776
  if (activity.type === 'Announce') return this.onAnnounce(activity, actor, objectId);
@@ -7,6 +7,7 @@ import * as wire from '../wire.mjs';
7
7
  import * as wireQuotes from '../wire-quotes.mjs';
8
8
  import * as containers from '../../pod/containers.mjs';
9
9
  import * as podNotes from '../../pod/notes.mjs';
10
+ import { totalsFor } from '../intake/counts.mjs';
10
11
 
11
12
  // Media container on the remote pod — public-Read like notes, created lazily
12
13
  // at first upload (idempotent; the flag only saves round-trips).
@@ -177,7 +178,7 @@ export async function noteToSelf(publisher, text) {
177
178
  // What the object is, who made it and when, where it lives and who it is
178
179
  // addressed to stay as they are, and so do a poll's choices and its count.
179
180
  const FIXED = new Set(['@context', 'id', 'type', 'attributedTo', 'published', 'to', 'cc', 'bto', 'bcc',
180
- 'audience', 'oneOf', 'anyOf', 'endTime', 'closed', 'votersCount', 'replies']);
181
+ 'audience', 'oneOf', 'anyOf', 'endTime', 'closed', 'votersCount', 'replies', 'likes', 'shares']);
181
182
  export async function updateObject(publisher, s, patch, { updated = new Date().toISOString() } = {}) {
182
183
  const { urls } = publisher;
183
184
  const doc = await podNotes.read(publisher.remote, s.noteId);
@@ -485,6 +486,7 @@ export async function updateNote(publisher, s, { content, spoilerText = null, se
485
486
  summary: spoilerText, sensitive: sensitive ?? !!s.sensitive, updated, container,
486
487
  also: [...new Set([...(s.named || []), ...(reply ? [reply.actor] : [])])],
487
488
  quote: rowQuote(s),
489
+ ...totalsFor(publisher.store, s.noteId),
488
490
  });
489
491
  await podNotes.write(publisher.remote, note.id, note);
490
492
  await podNotes.writeCreate(publisher.remote, wire.createActivityId(note.id), wire.createActivity(note, urls));
@@ -537,6 +539,7 @@ async function restateNote(publisher, s, { quote }) {
537
539
  summary: s.spoiler || null, sensitive: !!s.sensitive, updated: s.editedAt || null, container,
538
540
  also: [...(reply ? [reply.actor] : []), ...(s.quoteRequest?.actor ? [s.quoteRequest.actor] : [])],
539
541
  quote,
542
+ ...totalsFor(publisher.store, s.noteId),
540
543
  });
541
544
  await podNotes.write(publisher.remote, note.id, note);
542
545
  await podNotes.writeCreate(publisher.remote, wire.createActivityId(note.id), wire.createActivity(note, urls));
@@ -3,6 +3,7 @@
3
3
  // the activity and mirrors state into the store; the store keeps the
4
4
  // activity a later Undo must name.
5
5
 
6
+ import { forgetCounts } from './intake/counts.mjs';
6
7
  import crypto from 'node:crypto';
7
8
  import * as wire from './wire.mjs';
8
9
  import * as podNotes from '../pod/notes.mjs';
@@ -424,6 +425,7 @@ export async function deleteNote(agent, s) {
424
425
  }
425
426
  // Empty, and referenced by nothing once the note is gone.
426
427
  await podNotes.dropReplies(agent.remote, wire.repliesId(s.noteId));
428
+ forgetCounts(agent.store, s.noteId);
427
429
 
428
430
  // The Bluesky mirror goes with the post. Best-effort: a mirror the far side
429
431
  // already lost is not a reason to keep the post here.
@@ -177,21 +177,31 @@ export { podOnly };
177
177
  * An account's state kept at its gateway, reached through the gateway's state
178
178
  * API (lib/gateway/state-api.mjs) with the token it handed out. `holder` is
179
179
  * this agent's lease id: the gateway takes a write only from the lease's
180
- * holder, and `onRefused` hears when it would not. What stays on the pod is
181
- * read and written on `pod`.
180
+ * holder, and `onRefused` hears when it would not. `onGone` hears when the
181
+ * gateway keeps no copy of the account any more and will make none. What
182
+ * stays on the pod is read and written on `pod`.
182
183
  */
183
184
  export class StateApiStorage {
184
- constructor(base, { fetchImpl = globalThis.fetch, token, holder, pod = null, onRefused = null }) {
185
+ constructor(base, { fetchImpl = globalThis.fetch, token, holder, pod = null, onRefused = null, onGone = null }) {
185
186
  this.base = slash(base);
186
187
  this.fetchImpl = fetchImpl;
187
188
  this.token = token;
188
189
  this.holder = holder;
189
190
  this.pod = pod;
190
191
  this.onRefused = onRefused;
192
+ this.onGone = onGone;
191
193
  }
192
194
 
193
195
  get kind() { return 'copy'; }
194
196
 
197
+ async _gone(res) {
198
+ if (res.status !== 404 || !this.onGone) return false;
199
+ const said = await res.clone().json().catch(() => null);
200
+ if (!said?.gone) return false;
201
+ this.onGone();
202
+ return true;
203
+ }
204
+
195
205
  _ask(p, init = {}) {
196
206
  return this.fetchImpl(this.base + encodeURIComponent(p), {
197
207
  ...init, headers: { authorization: `Bearer ${this.token}`, ...(init.headers || {}) },
@@ -202,7 +212,7 @@ export class StateApiStorage {
202
212
  if (sub) return { notModified: false, names: [], etag: null };
203
213
  const res = await this._ask('', { headers: etag ? { 'if-none-match': etag } : {} });
204
214
  if (res.status === 304) return { notModified: true, names: null, etag };
205
- if (res.status >= 400) throw new Error(`the account's copy at the gateway is unreadable (HTTP ${res.status})`);
215
+ if (res.status >= 400) { await this._gone(res); throw new Error(`the account's copy at the gateway is unreadable (HTTP ${res.status})`); }
206
216
  const { names } = await res.json();
207
217
  return { notModified: false, names, etag: res.headers.get('etag') };
208
218
  }
@@ -211,7 +221,7 @@ export class StateApiStorage {
211
221
  if (podOnly(p)) return this.pod ? this.pod.read(p, opts) : { ok: false, notModified: false, status: 404, body: null, etag: null };
212
222
  const res = await this._ask(p, { headers: opts.etag ? { 'if-none-match': opts.etag } : {} });
213
223
  if (res.status === 304) return { ok: true, notModified: true, status: 304, body: null, etag: opts.etag };
214
- if (res.status >= 400) return { ok: false, notModified: false, status: res.status, body: null, etag: null };
224
+ if (res.status >= 400) { await this._gone(res); return { ok: false, notModified: false, status: res.status, body: null, etag: null }; }
215
225
  return { ok: true, notModified: false, status: res.status, body: await res.text(), etag: res.headers.get('etag') };
216
226
  }
217
227
 
@@ -221,6 +231,7 @@ export class StateApiStorage {
221
231
  const res = await this._ask(p, { method: 'PUT', headers: { 'content-type': contentType, 'x-fedipod-holder': this.holder }, body });
222
232
  if (res.status < 400) return { ok: true, retry: false, why: '' };
223
233
  if (res.status === 409) { this.onRefused?.(); return { ok: false, retry: false, why: 'another agent holds this account now', lost: true }; }
234
+ if (await this._gone(res)) return { ok: false, retry: false, why: 'the gateway keeps no copy of this account now', lost: true };
224
235
  return { ok: false, retry: res.status >= 500 || res.status === 429, why: `HTTP ${res.status}`, retryAfterMs: 0 };
225
236
  } catch (e) {
226
237
  return { ok: false, retry: true, why: e.message, retryAfterMs: 0 };
@@ -232,11 +243,49 @@ export class StateApiStorage {
232
243
  try {
233
244
  const res = await this._ask(p, { method: 'DELETE', headers: { 'x-fedipod-holder': this.holder } });
234
245
  if (res.status === 409) { this.onRefused?.(); return false; }
246
+ if (await this._gone(res)) return false;
235
247
  return res.status < 400 || res.status === 404;
236
248
  } catch { return false; }
237
249
  }
238
250
  }
239
251
 
252
+ /**
253
+ * A pod's state container that more than one agent may act on: the owner's
254
+ * browser, and the gateway acting for an app while the hold is off. With a
255
+ * `fence` set, every write first asks it whether this agent still holds the
256
+ * account, and a "no" is refused and told to `onRefused`, as the gateway's
257
+ * copy refuses a write from anyone but its lease's holder. Without one, it is
258
+ * the storage it wraps.
259
+ */
260
+ export class FencedStorage {
261
+ constructor(inner, { fence = null, onRefused = null } = {}) {
262
+ this.inner = inner;
263
+ this.fence = fence;
264
+ this.onRefused = onRefused;
265
+ }
266
+
267
+ get kind() { return this.inner.kind; }
268
+ get base() { return this.inner.base; }
269
+ list(sub, opts) { return this.inner.list(sub, opts); }
270
+ read(p, opts) { return this.inner.read(p, opts); }
271
+
272
+ async _refused() {
273
+ if (!this.fence || await this.fence().catch(() => true)) return false;
274
+ this.onRefused?.();
275
+ return true;
276
+ }
277
+
278
+ async write(p, body, contentType) {
279
+ if (await this._refused()) return { ok: false, retry: false, why: 'another agent holds this account now', lost: true };
280
+ return this.inner.write(p, body, contentType);
281
+ }
282
+
283
+ async remove(p) {
284
+ if (await this._refused()) return false;
285
+ return this.inner.remove(p);
286
+ }
287
+ }
288
+
240
289
  // `file:` (or a bare path) is a directory; anything else is a pod.
241
290
  export function storageFor(base, fetchImpl) {
242
291
  return /^https?:/i.test(base) ? new HttpStorage(base, fetchImpl) : new FileStorage(base);