fedipod-server 0.11.0 → 0.13.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 (152) hide show
  1. package/README.md +22 -6
  2. package/dist/claims.d.ts +8 -0
  3. package/dist/claims.js +10 -0
  4. package/dist/handler.d.ts +13 -0
  5. package/dist/handler.js +56 -11
  6. package/dist/handler.jsonld +8 -0
  7. package/dist/store-pod.js +18 -4
  8. package/lib/{c2s.mjs → client/c2s.mjs} +10 -3
  9. package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
  10. package/lib/client/masto/accounts.mjs +264 -0
  11. package/lib/client/masto/body.mjs +69 -0
  12. package/lib/client/masto/index.mjs +183 -0
  13. package/lib/client/masto/instance.mjs +104 -0
  14. package/lib/client/masto/media.mjs +133 -0
  15. package/lib/client/masto/oauth.mjs +599 -0
  16. package/lib/client/masto/render.mjs +459 -0
  17. package/lib/client/masto/statuses.mjs +331 -0
  18. package/lib/client/masto/timelines.mjs +316 -0
  19. package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
  20. package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
  21. package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
  22. package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
  23. package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
  24. package/lib/{import.mjs → connections/import.mjs} +1 -1
  25. package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +3 -3
  26. package/lib/connections/vault.mjs +114 -0
  27. package/lib/core/as2.mjs +170 -0
  28. package/lib/core/contexts/activitystreams.json +379 -0
  29. package/lib/core/contexts/did-v1.json +57 -0
  30. package/lib/core/contexts/fep-5711.json +36 -0
  31. package/lib/core/contexts/gotosocial.json +86 -0
  32. package/lib/core/contexts/identity-v1.json +152 -0
  33. package/lib/core/contexts/index.mjs +45 -0
  34. package/lib/core/contexts/join-lemmy.json +33 -0
  35. package/lib/core/contexts/joinmastodon.json +28 -0
  36. package/lib/core/contexts/map.json +16 -0
  37. package/lib/core/contexts/miscellany.json +19 -0
  38. package/lib/core/contexts/schemaorg.json +8845 -0
  39. package/lib/core/contexts/security-data-integrity-v1.json +78 -0
  40. package/lib/core/contexts/security-data-integrity-v2.json +81 -0
  41. package/lib/core/contexts/security-multikey-v1.json +35 -0
  42. package/lib/core/contexts/security-v1.json +74 -0
  43. package/lib/core/contexts/webfinger.json +10 -0
  44. package/lib/{deliver.mjs → core/deliver.mjs} +2 -2
  45. package/lib/core/graphview.mjs +269 -0
  46. package/lib/core/intake/activities.mjs +437 -0
  47. package/lib/core/intake/activity.mjs +240 -0
  48. package/lib/core/intake/channel.mjs +144 -0
  49. package/lib/core/intake/group.mjs +222 -0
  50. package/lib/core/intake/index.mjs +629 -0
  51. package/lib/core/intake/notes.mjs +288 -0
  52. package/lib/core/intake/verify.mjs +142 -0
  53. package/lib/{keys.mjs → core/keys.mjs} +1 -1
  54. package/lib/core/publisher/collections.mjs +229 -0
  55. package/lib/core/publisher/index.mjs +421 -0
  56. package/lib/core/publisher/notes.mjs +188 -0
  57. package/lib/core/publisher/questions.mjs +233 -0
  58. package/lib/core/publisher/restore.mjs +199 -0
  59. package/lib/core/shapes/activitystreams.ttl +129 -0
  60. package/lib/core/shapes/index.mjs +107 -0
  61. package/lib/core/shapes/shapes-text.mjs +13 -0
  62. package/lib/{social.mjs → core/social.mjs} +2 -2
  63. package/lib/{store.mjs → core/store.mjs} +4 -0
  64. package/lib/{wire.mjs → core/wire.mjs} +2 -2
  65. package/lib/device/admin/index.mjs +13 -0
  66. package/lib/device/admin/origins.mjs +35 -0
  67. package/lib/device/admin/routes/connections.mjs +144 -0
  68. package/lib/device/admin/routes/gateway.mjs +199 -0
  69. package/lib/device/admin/routes/lifecycle.mjs +191 -0
  70. package/lib/device/admin/routes/owner.mjs +322 -0
  71. package/lib/device/admin/routes/setup.mjs +393 -0
  72. package/lib/device/admin/routes/social.mjs +188 -0
  73. package/lib/device/admin/server.mjs +95 -0
  74. package/lib/device/admin/static.mjs +244 -0
  75. package/lib/device/admin/surface.mjs +274 -0
  76. package/lib/device/cli/commands/account.mjs +586 -0
  77. package/lib/device/cli/commands/run.mjs +278 -0
  78. package/lib/device/cli/commands/service.mjs +221 -0
  79. package/lib/device/cli/commands/setup.mjs +410 -0
  80. package/lib/device/cli/commands/state.mjs +559 -0
  81. package/lib/device/cli/context.mjs +288 -0
  82. package/lib/{migrate.mjs → device/migrate.mjs} +1 -1
  83. package/lib/{remote.mjs → device/remote.mjs} +3 -3
  84. package/lib/{setup.mjs → device/setup.mjs} +3 -3
  85. package/lib/{update.mjs → device/update.mjs} +1 -1
  86. package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
  87. package/lib/{front-core.mjs → gateway/front-core.mjs} +3 -3
  88. package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +1 -1
  89. package/lib/{httpsig.mjs → gateway/httpsig.mjs} +1 -1
  90. package/lib/server/embed.mjs +405 -0
  91. package/lib/{links.mjs → shared/links.mjs} +1 -1
  92. package/lib/{ua.mjs → shared/ua.mjs} +1 -1
  93. package/package.json +1 -1
  94. package/run-agent.mjs +33 -25
  95. package/web/admin/actors.js +145 -0
  96. package/web/admin/common.js +23 -0
  97. package/web/admin/connections.js +112 -0
  98. package/web/admin/gateway.js +111 -0
  99. package/web/admin/group.js +258 -0
  100. package/web/admin/index.html +7 -1
  101. package/web/admin/record.js +378 -0
  102. package/web/admin/setup/index.html +1 -0
  103. package/web/admin/setup/setup.js +2 -13
  104. package/web/admin/upkeep.js +170 -0
  105. package/web/app/README.md +6 -6
  106. package/web/app/admin-facade.mjs +3 -3
  107. package/web/app/agent.mjs +14 -16
  108. package/web/app/atproto-browser.mjs +1 -1
  109. package/web/app/boot.mjs +2 -3
  110. package/web/app/deliver-relay.mjs +1 -1
  111. package/web/app/dist/boot.js +22 -3
  112. package/web/app/dist/boot.js.map +2 -2
  113. package/web/app/dist/sw.js +21913 -5446
  114. package/web/app/dist/sw.js.map +4 -4
  115. package/web/app/fediacct-browser.mjs +1 -1
  116. package/web/app/keys-browser.mjs +27 -4
  117. package/web/app/shims/shapes-text.mjs +8 -0
  118. package/web/app/signup.mjs +2 -3
  119. package/web/app/site/admin/actors.js +145 -0
  120. package/web/app/site/admin/common.js +23 -0
  121. package/web/app/site/admin/connections.js +112 -0
  122. package/web/app/site/admin/gateway.js +111 -0
  123. package/web/app/site/admin/group.js +258 -0
  124. package/web/app/site/admin/index.html +7 -1
  125. package/web/app/site/admin/record.js +378 -0
  126. package/web/app/site/admin/setup/index.html +1 -0
  127. package/web/app/site/admin/setup/setup.js +2 -13
  128. package/web/app/site/admin/upkeep.js +170 -0
  129. package/web/app/site/boot.js +22 -3
  130. package/web/app/site/sw.js +21913 -5446
  131. package/web/app/sw-src.mjs +17 -2
  132. package/lib/admin.mjs +0 -1913
  133. package/lib/embed.mjs +0 -220
  134. package/lib/intake.mjs +0 -1981
  135. package/lib/mastoapi.mjs +0 -2284
  136. package/lib/publisher.mjs +0 -1192
  137. package/web/admin/admin.js +0 -1181
  138. package/web/app/site/admin/admin.js +0 -1181
  139. /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
  140. /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
  141. /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
  142. /package/lib/{lease.mjs → core/lease.mjs} +0 -0
  143. /package/lib/{polls.mjs → core/polls.mjs} +0 -0
  144. /package/lib/{proof.mjs → core/proof.mjs} +0 -0
  145. /package/lib/{storage.mjs → core/storage.mjs} +0 -0
  146. /package/lib/{account.mjs → device/account.mjs} +0 -0
  147. /package/lib/{certs.mjs → device/certs.mjs} +0 -0
  148. /package/lib/{export-collections.mjs → device/export-collections.mjs} +0 -0
  149. /package/lib/{home.mjs → device/home.mjs} +0 -0
  150. /package/lib/{ports.mjs → device/ports.mjs} +0 -0
  151. /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
  152. /package/lib/{safefetch.mjs → shared/safefetch.mjs} +0 -0
@@ -0,0 +1,240 @@
1
+ // activity.mjs — what an inbound activity IS, before anything is done with
2
+ // it: the AS2 types the fediverse actually posts, the caps that bound what a
3
+ // stranger can make the agent hold, and the pure checks on ids, origins and
4
+ // authorship that every other intake module shares.
5
+
6
+ // A note's replies collection is rewritten WHOLE every time one is added, so
7
+ // without a cap the bytes are quadratic in a number a stranger chooses.
8
+ export const MAX_REPLIES_RECORDED = 500;
9
+ // Ids of activities already forwarded to our followers (§7.1.2), so a re-drain
10
+ // never re-broadcasts one. A ceiling on the record, not on forwarding.
11
+ export const MAX_FORWARDED = 2000;
12
+ // How many posts may wait for a group operator's decision. A ceiling, not a
13
+ // window: full refuses the newest rather than dropping the oldest.
14
+ export const MAX_PENDING_REVIEW = 500;
15
+ // A group's membership, cached: it changes when someone joins or leaves, and
16
+ // re-reading it on every arriving post would spend one stranger's fetch per
17
+ // message on a list that moves in days.
18
+ export const CO_MEMBER_TTL_MS = 24 * 60 * 60_000;
19
+ export const CO_MEMBER_MAX = 5000;
20
+ // An activity is a few kB. This is generous by two orders of magnitude and
21
+ // still bounds what one Append can make us hold in memory.
22
+ export const MAX_ITEM_BYTES = 512 * 1024;
23
+ // The AS2 actor types. A group is as much an actor as a person is.
24
+ export const ACTOR_TYPES = new Set(['Person', 'Group', 'Service', 'Application', 'Organization']);
25
+ // The AS2 types the fediverse actually posts. `Note` alone is Mastodon's world
26
+ // and not the fediverse's: an Article is a Plume or WriteFreely post, a
27
+ // Question is a poll, a Video is PeerTube, a Page is Lemmy, an Audio is
28
+ // Funkwhale. Insisting on Note dead-lettered every one of them as "not a
29
+ // verifiable Note" — from people the owner had chosen to follow, silently.
30
+ //
31
+ // They share the shape this code reads: attributedTo, content, published,
32
+ // inReplyTo, tag, attachment. A poll's options are dropped, which is a
33
+ // degraded rendering rather than a lost post.
34
+ export const CONTENT_TYPES = new Set(['Note', 'Article', 'Question', 'Page', 'Video', 'Audio', 'Image', 'Event']);
35
+
36
+ // A Question is a poll: its options live in oneOf (pick one) or anyOf (pick
37
+ // several), each carrying the tally its author's server maintains.
38
+ // Custom emojis ride the tag list; the images live at the author's server and
39
+ // the client fetches them from there.
40
+ // Content, name and summary were capped; nothing else was. One remote Question
41
+ // with thousands of options, or thousands of emoji, mention or attachment
42
+ // entries, put megabytes into a single statuses.json row — a document rewritten
43
+ // whole on every change. These are display lists: past a few dozen, nothing can
44
+ // render them and nobody meant them to be rendered.
45
+ // http(s) only. An emoji, attachment or mention URL is written straight into
46
+ // the client's markup, so `javascript:` and `data:` have no business in one.
47
+ // The store has guarded avatars this way all along (safeUrl, lib/store.mjs);
48
+ // these three lists were simply never put through it.
49
+ export const httpOnly = (u) => {
50
+ if (!u) return null;
51
+ try {
52
+ const p = new URL(String(u));
53
+ return (p.protocol === 'https:' || p.protocol === 'http:') ? String(u) : null;
54
+ } catch { return null; }
55
+ };
56
+
57
+ export const MAX_MODQUEUE = 200;
58
+ export const MAX_EMOJIS = 60;
59
+ export const MAX_POLL_OPTIONS = 50;
60
+ export const MAX_OPTION_CHARS = 200;
61
+ export const MAX_MENTIONS = 60;
62
+ export const MAX_URL_CHARS = 2048;
63
+
64
+ export function emojisOf(note) {
65
+ return [].concat(note?.tag || [])
66
+ .filter(t => t?.type === 'Emoji' && t.icon?.url && t.name)
67
+ .slice(0, MAX_EMOJIS)
68
+ .map(t => ({
69
+ shortcode: String(t.name).replace(/^:|:$/g, '').slice(0, 64),
70
+ url: httpOnly(String(t.icon.url).slice(0, MAX_URL_CHARS)),
71
+ }))
72
+ .filter(e => e.url);
73
+ }
74
+
75
+ export function pollOf(note) {
76
+ const opts = note?.oneOf || note?.anyOf;
77
+ if (!Array.isArray(opts) || !opts.length) return null;
78
+ return {
79
+ multiple: !!note.anyOf,
80
+ expiresAt: note.endTime || null,
81
+ closed: !!note.closed,
82
+ options: opts.slice(0, MAX_POLL_OPTIONS).map(o => ({
83
+ title: String(o?.name ?? '').slice(0, MAX_OPTION_CHARS),
84
+ votes: Number(o?.replies?.totalItems) || 0,
85
+ })),
86
+ };
87
+ }
88
+ // AS2 lets `type` be one string or a list, and implementations use both —
89
+ // `["Person","Service"]` is an ordinary actor. Read either form.
90
+ export const typesOf = (t) => (Array.isArray(t) ? t : [t]).filter(x => typeof x === 'string');
91
+ export const isContentType = (t) => typesOf(t).some(x => CONTENT_TYPES.has(x));
92
+ export const isActorType = (t) => typesOf(t).some(x => ACTOR_TYPES.has(x));
93
+
94
+ // What we will carry to our followers on someone else's behalf (§7.1.2): the
95
+ // activities a conversation is made of, and nothing else. A type this file
96
+ // does not handle falls out of handle() with no rejection, and "no rejection"
97
+ // is what qualifies an activity for forwarding — so without this gate a
98
+ // stranger could have anything at all, of a type nothing here reads,
99
+ // re-delivered to every follower over our signature.
100
+ export const FORWARDABLE = new Set(['Create', 'Update', 'Delete', 'Like', 'Announce', 'Undo']);
101
+ // Of those, the ones we may re-deliver to our own followers over our own
102
+ // signature (§7.1.2). Deliberately narrower than FORWARDABLE: these three are
103
+ // the ones whose object this drain fetched from the author's origin and checked
104
+ // before accepting. A Like, an Announce or an Undo is taken on the envelope's
105
+ // word alone — relaying one is signing for a claim nothing corroborated.
106
+ export const FORWARD_TYPES = new Set(['Create', 'Update', 'Delete']);
107
+ // How many forwards one drain may send. A reply into a busy thread of ours is a
108
+ // handful; anything near this is a flood using us as an amplifier.
109
+ export const MAX_FORWARDS_PER_DRAIN = 20;
110
+ export const ACCEPT_AP = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
111
+
112
+ // What is worth keeping of an activity we are filing rather than acting on.
113
+ //
114
+ // A dead letter, a moderation entry and a waiting follow request each used to
115
+ // hold the WHOLE delivered activity — up to the 512 KB item cap — in documents
116
+ // that are serialized and PUT whole on every change. A stranger could inflate
117
+ // deadletter.json to ~100 MB and requests.json to ~250 MB and make us rewrite
118
+ // them on every batch.
119
+ //
120
+ // Everything the consumers need survives: `acceptActivity` wraps this as the
121
+ // Accept's object (and {id, type, actor, object} IS the standard Follow shape),
122
+ // `applyModeration` reads only `object`, and a dead letter is read by a human
123
+ // who wants to know what arrived, not to replay it.
124
+ export function trimActivity(a) {
125
+ if (!a || typeof a !== 'object') return a ?? null;
126
+ const idOf = (v) => (typeof v === 'string' ? v : v?.id ?? null);
127
+ const out = {};
128
+ for (const k of ['id', 'type', 'actor', 'target']) {
129
+ const v = idOf(a[k]);
130
+ if (v) out[k] = String(v).slice(0, 2048);
131
+ }
132
+ const obj = idOf(a.object);
133
+ if (obj) out.object = String(obj).slice(0, 2048);
134
+ // A typed object with no id still says what it was — a Block of nobody, an
135
+ // Undo of a Follow — and that is the whole of what the queue reads.
136
+ else if (a.object && typeof a.object === 'object' && a.object.type) {
137
+ out.object = { type: String(a.object.type).slice(0, 64) };
138
+ }
139
+ return out;
140
+ }
141
+
142
+ // Same origin AND, where the origin holds more than one identity, the same
143
+ // identity within it.
144
+ //
145
+ // Origin alone is the right test when an origin is one person — the ordinary
146
+ // fediverse server, and a subdomain pod. It is the WRONG test on a path-based
147
+ // host, and above all on a multi-tenant front: every tenant of fedipod.net has
148
+ // ids under `https://fedipod.net/u/<name>/`, so origin-equality made every
149
+ // tenant able to vouch for every other. One tenant could publish posts
150
+ // attributed to another, Update a neighbour's post, or Delete it — and a
151
+ // receiving Mastodon would believe it for the same reason we did.
152
+ //
153
+ // So when both ids carry an identity prefix, the prefixes must match too. The
154
+ // two shapes that exist here are the front's `/u/<name>/` and a pod's own AP
155
+ // root (`…/<root>/ap/…`); anything else has no prefix and falls back to origin,
156
+ // which is what a plain remote server should be judged by.
157
+ function identityPrefix(u) {
158
+ const m = /^(https?:\/\/[^/]+\/u\/[^/]+\/)/u.exec(u);
159
+ if (m) return m[1];
160
+ const ap = /^(https?:\/\/[^/]+\/(?:[^/]+\/)*?)ap\//u.exec(u);
161
+ return ap ? ap[1] : null;
162
+ }
163
+
164
+ export function sameIdentity(a, b) {
165
+ if (!sameOrigin(a, b)) return false;
166
+ const pa = identityPrefix(String(a));
167
+ const pb = identityPrefix(String(b));
168
+ if (!pa || !pb) return true; // no prefix to compare: origin is the answer
169
+ return pa === pb;
170
+ }
171
+
172
+ export function sameOrigin(a, b) {
173
+ try { return new URL(a).origin === new URL(b).origin; } catch { return false; }
174
+ }
175
+
176
+ // Is this socket URL the pod's own? The scheme has to be the socket form of the
177
+ // pod's — wss for https, ws for http — so a downgrade to plaintext from an https
178
+ // pod is somewhere else, not the same place unencrypted.
179
+ //
180
+ // The host may be the pod's, or a PARENT of it. Not a loosening for
181
+ // convenience: a CSS server that gives every pod a subdomain answers
182
+ // notifications from the server root, so jeff-zucker.teamid.live is served by
183
+ // wss://teamid.live/.notifications/… — which is the deployment this project
184
+ // actually runs on. Requiring an exact match dropped it to polling, and the
185
+ // live agents are how that was found rather than the suite.
186
+ //
187
+ // A sibling subdomain is still refused: only a suffix of our own host passes,
188
+ // and two labels minimum so `.live` cannot pose as everyone's parent. Not a
189
+ // public-suffix list — that is a dependency and a data file to keep current,
190
+ // and the party this guards against is the pod you already chose to trust.
191
+ //
192
+ // `localhost` is the one single-label parent allowed, because it is the one that
193
+ // cannot be anybody else: it is reserved to the loopback interface (RFC 6761),
194
+ // so `alice.localhost` and `localhost` are the same machine by definition and
195
+ // there is no stranger for the rule to keep out. Without this a pod served from
196
+ // a subdomain of localhost — which is how a Solid server with subdomain pods
197
+ // runs on a developer's machine — refused its own socket and fell back to
198
+ // polling, so every delivery waited up to two minutes.
199
+ export function sameSocketOrigin(socketUrl, podBase) {
200
+ let s, p;
201
+ try { s = new URL(socketUrl); p = new URL(podBase); } catch { return false; }
202
+ if (s.protocol !== (p.protocol === 'https:' ? 'wss:' : 'ws:')) return false;
203
+ if (s.host === p.host) return true;
204
+ const parent = s.hostname.toLowerCase();
205
+ return (parent.split('.').length >= 2 || parent === 'localhost')
206
+ && s.port === p.port
207
+ && p.hostname.toLowerCase().endsWith('.' + parent);
208
+ }
209
+
210
+ export function httpUrl(u) {
211
+ try {
212
+ const p = new URL(String(u)).protocol;
213
+ return p === 'https:' || p === 'http:';
214
+ } catch { return false; }
215
+ }
216
+
217
+ // Who a note is BY. A document may only speak for an actor at its own origin.
218
+ // `attributedTo` used to be taken at face value, so a note served anywhere
219
+ // could name anyone: one at a host the attacker controls, claiming to be by
220
+ // someone the owner follows, passed every check we had — the envelope's
221
+ // sameOrigin compares the ACTIVITY to its object, never the object to its
222
+ // author — and landed in the home timeline and in the pod as them. For a group
223
+ // it went further still, because amplify() gates on the author's membership,
224
+ // so the group signed an Announce of it and delivered it to every member.
225
+ //
226
+ // `delivered` is the actor that brought it, used only when the note names no
227
+ // author of its own; it has to clear the same test, which is why a boost of an
228
+ // unattributed note is refused rather than credited to the booster.
229
+ //
230
+ // Returns the author, or null when nothing at the note's origin vouches for one.
231
+ export function authorOf(note, delivered = null) {
232
+ const claimed = [].concat(note?.attributedTo || [])
233
+ .map(a => (typeof a === 'string' ? a : a?.id)).find(Boolean) || null;
234
+ const author = claimed || delivered;
235
+ if (!author) return null;
236
+ // sameIdentity, not sameOrigin: on a multi-tenant front every tenant shares
237
+ // an origin, so origin-equality let any of them be credited with any other's
238
+ // post. See sameIdentity.
239
+ return sameIdentity(note?.id, author) ? author : null;
240
+ }
@@ -0,0 +1,144 @@
1
+ // channel.mjs — the inbox's push channel: a WebSocketChannel2023 subscription
2
+ // on the pod's inbox container, reconnecting with backoff, and the drain it
3
+ // wakes. State (ws, wsState, reconnectTries, resubTimer) lives on the Intake
4
+ // so status pages and stop() see it; this module is the behaviour.
5
+
6
+ import * as podNotifications from '../../pod/notifications.mjs';
7
+ import { USER_AGENT } from '../../shared/ua.mjs';
8
+ import { HTTP_TIMEOUT_MS, readCapped } from '../../shared/safefetch.mjs';
9
+ import { sameSocketOrigin } from './activity.mjs';
10
+
11
+ // The channel a subscription returns outlives a dropped socket, so reconnecting
12
+ // reuses it. Creating a new one per reconnect is what buried solidcommunity.net
13
+ // in channel records they then had to sweep.
14
+ const CHANNEL_DOC = 'inbox-channel.json';
15
+ // A flapping socket used to POST a NEW WebSocketChannel2023 channel every two
16
+ // seconds — hundreds an hour against a server that is already struggling, and
17
+ // channel churn its operators have to sweep up. Backs off instead, and an open
18
+ // only triggers a sweep if we have not just swept.
19
+ const RECONNECT_MIN_MS = 2_000;
20
+ const RECONNECT_MAX_MS = 5 * 60_000;
21
+ // How long a socket must stay up before the backoff counts it as a success and
22
+ // resets. Shorter than that is a flap, not a connection.
23
+ const RECONNECT_STABLE_MS = 60_000;
24
+ const OPEN_DRAIN_MIN_GAP_MS = 30_000;
25
+
26
+ // Jittered exponential, floor to ceiling, reset by a successful open.
27
+ export function reconnectDelay(intake) {
28
+ const capped = Math.min(RECONNECT_MIN_MS * 2 ** intake.reconnectTries, RECONNECT_MAX_MS);
29
+ intake.reconnectTries++;
30
+ return Math.round(capped * (0.8 + Math.random() * 0.4));
31
+ }
32
+
33
+ // --- push ---
34
+ // Any failure in here used to end push for the life of the process: the
35
+ // retry lived only in the "server refused the subscription" branch, so a
36
+ // network blip left wsState at never-connected and the agent silently on
37
+ // polling. Every path now schedules a retry on the same backoff.
38
+ export async function subscribe(intake) {
39
+ try {
40
+ await intake._subscribeOnce();
41
+ } catch (e) {
42
+ intake.wsState = 'subscribe-error';
43
+ const wait = intake._reconnectDelay();
44
+ intake.log(`subscribe failed (${e.message}) — retrying in ${Math.round(wait / 1000)}s (polling meanwhile)`);
45
+ if (!intake.stopped) {
46
+ intake.resubTimer = setTimeout(() => intake.subscribe().catch(() => {}), wait);
47
+ intake.resubTimer.unref?.();
48
+ }
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Where this pod describes the services it offers. The pod says so on any
54
+ * response about one of its resources; the well-known path is only what a
55
+ * pod that says nothing has always used.
56
+ */
57
+ export async function storageDescriptionUrl(intake) {
58
+ return podNotifications.storageDescriptionUrl(intake.urls.base,
59
+ { headers: { 'user-agent': USER_AGENT }, timeoutMs: HTTP_TIMEOUT_MS });
60
+ }
61
+
62
+ export async function subscribeOnce(intake) {
63
+ // Reuse a channel we already have rather than asking for another one.
64
+ const saved = intake.store.read(CHANNEL_DOC, null);
65
+ if (saved?.receiveFrom && (!saved.endAt || Date.parse(saved.endAt) - Date.now() > 60_000)) {
66
+ intake._openSocket(saved.receiveFrom, true);
67
+ return;
68
+ }
69
+ const descUrl = await intake._storageDescriptionUrl();
70
+ const { channel, error } = await podNotifications.readWebSocketChannel(descUrl,
71
+ { headers: { 'user-agent': USER_AGENT }, timeoutMs: HTTP_TIMEOUT_MS });
72
+ if (!channel) { intake.wsState = 'unavailable'; intake.log(`${error} — polling only`); return; }
73
+ // The topic is a POD resource, and it travels in the BODY — so the url map
74
+ // RemotePod applies to the request line never reaches it. A fronted
75
+ // identity's inbox url names the front, which the pod cannot grant read on,
76
+ // and the subscription came back 403. A no-op when unfronted.
77
+ const topic = intake.urls.toPod ? intake.urls.toPod(intake.urls.inbox) : intake.urls.inbox;
78
+ const sub = await podNotifications.subscribeToInbox(intake.remote,
79
+ { channelUrl: channel, podTopicUrl: topic });
80
+ const body = await readCapped(sub).then(JSON.parse).catch(() => null);
81
+ if (!body?.receiveFrom) {
82
+ intake.wsState = `subscribe-failed-${sub.status}`;
83
+ const wait = intake._reconnectDelay();
84
+ intake.log(`subscription failed (${sub.status}) — retrying in ${Math.round(wait / 1000)}s (polling meanwhile)`);
85
+ if (!intake.stopped) {
86
+ intake.resubTimer = setTimeout(() => intake.subscribe().catch(e => intake.log(`resubscribe: ${e.message}`)), wait);
87
+ intake.resubTimer.unref?.();
88
+ }
89
+ return;
90
+ }
91
+ intake.store.write(CHANNEL_DOC, { receiveFrom: body.receiveFrom, endAt: body.endAt || null });
92
+ intake._openSocket(body.receiveFrom, false);
93
+ }
94
+
95
+ // The socket URL arrives in the pod's own subscription response, and it was
96
+ // the one outbound address in the project that reached the network without
97
+ // passing anything — safefetch guards every fetch, and `new WebSocket()` is
98
+ // not a fetch. A pod that answered with somebody else's address had us open a
99
+ // long-lived connection there and treat what came back as our inbox waking up.
100
+ //
101
+ // Same origin as the pod, not assertPublicUrl: a pod on this machine is a
102
+ // documented setup and its socket is legitimately ws://localhost:3000, which
103
+ // a public-address check would refuse.
104
+ export function openSocket(intake, receiveFrom, reused) {
105
+ if (!sameSocketOrigin(receiveFrom, intake.urls.base)) {
106
+ intake.wsState = 'refused';
107
+ intake.log(`subscription named ${receiveFrom}, which is not this pod — polling only`);
108
+ if (reused) intake.store.write(CHANNEL_DOC, null);
109
+ return;
110
+ }
111
+ intake.ws = new WebSocket(receiveFrom);
112
+ intake.ws.onopen = () => {
113
+ intake.wsState = 'open';
114
+ if (!intake._announcedPush) { intake.log('inbox push subscription active'); intake._announcedPush = true; }
115
+ intake._openedAt = Date.now();
116
+ // Anything that arrived while the socket was down is waiting — sweep it,
117
+ // unless a sweep just ran: a flapping socket must not re-list the inbox
118
+ // on every open.
119
+ if (Date.now() - intake.lastDrainAtMs > OPEN_DRAIN_MIN_GAP_MS) {
120
+ intake.drain().catch(e => intake.log(`drain: ${e.message}`));
121
+ }
122
+ };
123
+ intake.ws.onmessage = () => intake.drain().catch(e => intake.log(`drain: ${e.message}`));
124
+ intake.ws.onclose = () => {
125
+ intake.wsState = 'closed';
126
+ // Only a connection that STAYED up counts as a success. Resetting on open
127
+ // alone meant the 2026-07-29 failure — a server that accepts the upgrade
128
+ // and then drops the socket on a crash cycle — reconnected at the 2s
129
+ // floor indefinitely: every cycle "succeeded", so the exponential cap was
130
+ // never reached, and each open also drained the inbox.
131
+ if (intake._openedAt && Date.now() - intake._openedAt >= RECONNECT_STABLE_MS) intake.reconnectTries = 0;
132
+ intake._openedAt = 0;
133
+ if (!intake.stopped) {
134
+ intake.resubTimer = setTimeout(() => intake.subscribe().catch(e => intake.log(`resubscribe: ${e.message}`)), intake._reconnectDelay());
135
+ intake.resubTimer.unref?.();
136
+ }
137
+ };
138
+ intake.ws.onerror = () => {
139
+ intake.wsState = 'error';
140
+ // A channel we reused may simply be gone: forget it so the next attempt
141
+ // asks for a fresh one instead of retrying a dead URL forever.
142
+ if (reused) intake.store.write(CHANNEL_DOC, null);
143
+ };
144
+ }
@@ -0,0 +1,222 @@
1
+ // group.mjs — what a group actor does with what arrives (FEP-1b12): carries a
2
+ // member's post to the membership, knows who its co-members are, and queues a
3
+ // listed moderator's ask for the operator rather than running it.
4
+
5
+ import { trimActivity, MAX_MODQUEUE, MAX_PENDING_REVIEW, CO_MEMBER_TTL_MS, CO_MEMBER_MAX } from './activity.mjs';
6
+
7
+ // Which inbound activities count as a moderator's ask: a ban, an unban, a
8
+ // post removal, or a roster change naming OUR moderators collection. A
9
+ // moderator's ordinary traffic (their posts, likes, follows) is not
10
+ // moderation and takes the normal arms.
11
+ export function isModerationAsk(intake, activity) {
12
+ if (activity.type === 'Block') return true;
13
+ if (activity.type === 'Undo') {
14
+ return typeof activity.object === 'object' && activity.object?.type === 'Block';
15
+ }
16
+ if (activity.type === 'Delete') {
17
+ const id = typeof activity.object === 'string' ? activity.object : activity.object?.id;
18
+ const s = id && intake.store.getStatuses().find(x => x.noteId === id);
19
+ // Only a post the group holds and did not author — removing those is
20
+ // moderation; everything else is the author's own Delete.
21
+ return !!s && s.kind !== 'post';
22
+ }
23
+ if (activity.type === 'Add' || activity.type === 'Remove') {
24
+ const target = typeof activity.target === 'string' ? activity.target : activity.target?.id;
25
+ return target === intake.urls.moderators;
26
+ }
27
+ return false;
28
+ }
29
+
30
+ // Held, not run: one entry per distinct ask, capped, waiting for the
31
+ // operator to apply or dismiss it (social.applyModeration).
32
+ // A moderator's WORD, not their proof. `actor` is a field in an unsigned
33
+ // body and a moderator's URL is public, so anyone can claim to be one — which
34
+ // is exactly why these are QUEUED for the operator rather than run. What was
35
+ // missing is that the queue did not say which is which, and a stranger could
36
+ // fill all 200 slots and push the real asks out.
37
+ //
38
+ // So: the entry records whether the door vouched for the sender, and when the
39
+ // queue is full the UNVERIFIED entries are what get dropped. A real
40
+ // moderator's ask cannot be crowded out by someone impersonating them.
41
+ export function queueModeration(intake, activity, actor, { trusted = false } = {}) {
42
+ const q = intake.store.read('modqueue.json', []);
43
+ const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
44
+ const key = [activity.type, actor, objectId || JSON.stringify(activity.object || null)].join(' ');
45
+ const seen = q.find(e => e.key === key);
46
+ if (seen) {
47
+ // The same ask arriving verified is worth more than the copy we hold.
48
+ if (trusted && !seen.verified) {
49
+ seen.verified = true;
50
+ intake.store.write('modqueue.json', q);
51
+ }
52
+ return;
53
+ }
54
+ q.unshift({
55
+ key, id: (intake.serial++).toString(36) + '-' + q.length,
56
+ type: activity.type, moderator: actor, activity: trimActivity(activity),
57
+ verified: !!trusted, at: new Date().toISOString(),
58
+ });
59
+ let kept = q;
60
+ if (kept.length > MAX_MODQUEUE) {
61
+ const verified = kept.filter(e => e.verified);
62
+ const rest = kept.filter(e => !e.verified);
63
+ // Verified first, then the newest unverified up to the cap.
64
+ kept = [...verified, ...rest].slice(0, MAX_MODQUEUE);
65
+ }
66
+ intake.store.write('modqueue.json', kept);
67
+ intake.log(`moderation queued from ${actor}${trusted ? '' : ' (unverified)'}: ${activity.type} ${objectId || ''}`);
68
+ }
69
+
70
+ // Anyone can Append to a public inbox, so arriving is not the same as being
71
+ // carried to every follower. Membership is the gate: you cannot post to a
72
+ // group you have not joined, and declining to carry a member is the only
73
+ // moderation a group can actually enforce.
74
+ export async function amplify(intake, noteId, { approved = false, activity = null } = {}) {
75
+ const s = intake.store.getStatuses().find(x => x.noteId === noteId);
76
+ if (!s) return;
77
+ if (s.announcedAt) return; // a re-delivered Create announces once
78
+ // A DM to the group, or a followers-only post it happened to receive, was
79
+ // addressed to less than the world — carrying it would widen the author's
80
+ // audience for them. A group only ever amplifies public posts.
81
+ if (s.direct || s.nonPublic) {
82
+ intake.log(`not amplified — ${noteId} was not addressed publicly, and a group never widens a post's audience`);
83
+ return;
84
+ }
85
+ const contacts = intake.store.getContacts();
86
+ if (!contacts.followers.some(f => f.actor === s.actor)) {
87
+ intake.log(`not amplified — ${s.actor} is not a member`);
88
+ return;
89
+ }
90
+ if (intake.store.getMuted().actors.includes(s.actor)) {
91
+ intake.log(`not amplified — ${s.actor} is muted`);
92
+ return;
93
+ }
94
+ // A reviewed group carries nothing until its operator says so.
95
+ if (intake.config.review && !approved) {
96
+ const pending = intake.store.getPending();
97
+ if (!pending.some(p => p.noteId === noteId)) {
98
+ // Full means refuse the new one, not evict the oldest. `slice(0, 500)`
99
+ // dropped from the tail, so one member posting 500 notes silently
100
+ // discarded everything the operator was still deciding about — the
101
+ // posts were never carried, never refused, and left no record that they
102
+ // had ever arrived. Becoming a member costs one Follow when joins are
103
+ // unmoderated, which is the default.
104
+ //
105
+ // Not carrying it is what a reviewed group does with anything it has
106
+ // not approved, so refusing is the same outcome the queue was for.
107
+ if (pending.length >= MAX_PENDING_REVIEW) {
108
+ intake.log(`review queue is full (${MAX_PENDING_REVIEW}) — ${noteId} not held. `
109
+ + 'Approve or decline what is waiting and it will be carried on redelivery.');
110
+ return;
111
+ }
112
+ // The activity rides along: approving later still has to wrap the one
113
+ // the member actually sent, not a reconstruction of it.
114
+ pending.unshift({ noteId, actor: s.actor, activity, at: new Date().toISOString() });
115
+ intake.store.setPending(pending);
116
+ }
117
+ intake.log(`held for review: ${noteId}`);
118
+ return;
119
+ }
120
+ // A member's Bluesky post: the carry is a native repost by the group's
121
+ // account. It reaches AP followers only through the author's own bridge —
122
+ // the group never fabricates an AP object for someone else's words.
123
+ if (s.kind === 'bsky') {
124
+ if (!intake.bskyGroup) { intake.log(`not amplified — ${noteId} is a bluesky post and no account is connected`); return; }
125
+ return intake.bskyGroup.carry(s);
126
+ }
127
+ const held = intake.store.getPending().find(p => p.noteId === noteId);
128
+ const inboxes = intake.announceTargets(s.actor);
129
+ const { announceActivity } = await import('../wire.mjs');
130
+ // Wrap the member's own activity when we have it; a bare note URL is the
131
+ // fallback, and renders as a plain boost rather than a group carry. The
132
+ // group names itself as the audience (FEP-1b12).
133
+ //
134
+ // `activity` is the envelope as DELIVERED — a document the sender wrote,
135
+ // which the group would otherwise re-sign and hand to every follower with
136
+ // whatever addressing, tags and object body it carried. Only the note id was
137
+ // ever verified (ingestNote fetched it from the author's origin and checked
138
+ // the attribution), so only the note id is safe to pass on: send the bare
139
+ // id unless the wrapper's own object id agrees with what we verified.
140
+ const wrapperObject = (a) => {
141
+ const inner = a?.object;
142
+ const id = typeof inner === 'string' ? inner : inner?.id;
143
+ return id === noteId ? a : null;
144
+ };
145
+ const act = announceActivity({
146
+ urls: intake.urls,
147
+ object: wrapperObject(activity) || wrapperObject(held?.activity) || noteId,
148
+ serial: intake.serial++,
149
+ audience: intake.urls.actor,
150
+ });
151
+ await intake.deliverer.deliverToAll(inboxes, act);
152
+ // Marked carried before recorded: a failed outbox write costs one missing
153
+ // entry, a failed status write would carry the same post twice.
154
+ intake.store.updateStatus(noteId, { announcedAt: new Date().toISOString(), announceActivity: act });
155
+ await intake.publisher.recordOutbox(act);
156
+ intake.store.setPending(intake.store.getPending().filter(p => p.noteId !== noteId));
157
+ intake.log(`amplified ${noteId} → ${inboxes.length} inbox(es)`);
158
+ // The same carry, shown natively to the group's Bluesky followers.
159
+ await intake.bskyGroup?.mirrorCarry(s)
160
+ .catch(e => intake.log(`bluesky mirror of the carry failed: ${e.message}`));
161
+ }
162
+
163
+ // Is this actor in a group we are in? Each followed Group's membership is a
164
+ // public collection, read at most once a day and cached — a membership list
165
+ // is slow-moving, and this runs on arriving mail.
166
+ export async function isCoMember(intake, actor) {
167
+ if (intake.config.kind === 'group') return false; // a group has members, not peers
168
+ const groups = intake.store.getContacts().following
169
+ .filter(f => f.accepted && intake.store.getActors()[f.actor]?.type === 'Group')
170
+ .map(f => f.actor);
171
+ if (!groups.length) return false;
172
+ const cache = intake.store.read('comembers.json', {});
173
+ const fresh = Date.now() - CO_MEMBER_TTL_MS;
174
+ let changed = false;
175
+ for (const g of groups) {
176
+ const held = cache[g];
177
+ if (held && Date.parse(held.at || 0) > fresh) continue;
178
+ const doc = await intake.fetchAP(g).catch(() => null);
179
+ const list = doc?.followers ? await intake.collectionMembers(doc.followers) : null;
180
+ // A list we could not read keeps whatever we had: losing it would demote
181
+ // every co-member to a stranger for a day because one fetch failed.
182
+ if (!list) continue;
183
+ cache[g] = { at: new Date().toISOString(), members: list };
184
+ changed = true;
185
+ }
186
+ if (changed) intake.store.write('comembers.json', cache);
187
+ return groups.some(g => cache[g]?.members?.includes(actor));
188
+ }
189
+
190
+ // The actor ids in a (possibly paged) public collection, capped.
191
+ export async function collectionMembers(intake, url) {
192
+ const out = [];
193
+ let next = url;
194
+ for (let page = 0; next && page < 10 && out.length < CO_MEMBER_MAX; page++) {
195
+ const doc = await intake.fetchAP(next).catch(() => null);
196
+ if (!doc) return out.length ? out : null;
197
+ for (const item of doc.orderedItems || doc.items || []) {
198
+ if (typeof item === 'string') out.push(item);
199
+ }
200
+ next = doc.first && page === 0 ? doc.first : doc.next;
201
+ if (typeof next === 'object') next = next?.id;
202
+ }
203
+ return out;
204
+ }
205
+
206
+ // Who an Announce for `author` goes to. Shared with the retract path: an Undo
207
+ // that reached a different set than the Announce did would leave the post
208
+ // standing for whoever the two sets disagreed about.
209
+ // The author's own target is dropped only when it serves nobody else — a
210
+ // shared inbox carries the whole server's members.
211
+ export function announceTargets(intake, author) {
212
+ const byTarget = new Map();
213
+ for (const f of intake.store.getContacts().followers) {
214
+ const t = f.sharedInbox || f.inbox;
215
+ if (!t) continue;
216
+ if (!byTarget.has(t)) byTarget.set(t, new Set());
217
+ byTarget.get(t).add(f.actor);
218
+ }
219
+ return [...byTarget]
220
+ .filter(([, who]) => !(who.size === 1 && who.has(author)))
221
+ .map(([t]) => t);
222
+ }