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,437 @@
1
+ // activities.mjs — one handler per inbound activity type. Each is reached
2
+ // from Intake.handle() after the sender passed the door checks, and returns a
3
+ // rejection reason or nothing.
4
+
5
+ import { dropFollower } from '../store.mjs';
6
+ import { emojisOf, pollOf, isContentType, trimActivity } from './activity.mjs';
7
+
8
+ // §7.6 Add / §7.9 Remove. The side effect would be to add or remove the object
9
+ // to/from the collection named in `target` — but only a collection we own AND
10
+ // that the sender is authorised to modify. No remote is granted write to our
11
+ // collections (membership is Follow/Undo, pins are ours to set), so there is
12
+ // nothing an inbound Add or Remove may change here. It is a valid activity,
13
+ // not garbage: acknowledge it, make no change, and never dead-letter it.
14
+ export function onAddRemove(intake, activity, actor) {
15
+ const target = typeof activity.target === 'string' ? activity.target : activity.target?.id;
16
+ const ours = target && [intake.urls.followers, intake.urls.following, intake.urls.featured]
17
+ .filter(Boolean).includes(target);
18
+ intake.log(ours
19
+ ? `${activity.type} from ${actor} targets our ${target} — no remote may modify it; acknowledged`
20
+ : `${activity.type} from ${actor} targets ${target || 'no collection of ours'}; nothing here to change`);
21
+ return; // accepted, no side effect
22
+ }
23
+
24
+ export async function onFollow(intake, activity, actor, { trusted = false } = {}) {
25
+ const doc = await intake.fetchAP(actor); // origin must vouch for the actor
26
+ if (!doc) return `actor fetch failed (${actor})`;
27
+ if (doc.id !== actor) return `actor id mismatch (${actor} vs ${doc.id})`;
28
+ if (!doc.inbox) return `actor has no inbox (${actor})`;
29
+ const contacts = intake.store.getContacts();
30
+ const existing = contacts.followers.find(f => f.actor === actor);
31
+ // NOTHING binds a delivered Follow to the actor it names. LDN bodies carry
32
+ // no signature, and unlike Create, Delete and Update there is no object at
33
+ // the origin to re-fetch and compare — dereferencing the actor proves only
34
+ // that the actor EXISTS. So anyone at all could Append a Follow naming
35
+ // anyone at all, and we would sign an Accept, deliver it to that person,
36
+ // and send them everything published from then on.
37
+ //
38
+ // Until deliveries terminate somewhere their signature survives, a follow
39
+ // we cannot verify is a REQUEST, waiting in the same queue a gated group
40
+ // uses. The requester's client shows "Requested", which is the ordinary
41
+ // locked-account state that manuallyApprovesFollowers tells it to expect.
42
+ // `autoAcceptFollows: true` in config restores the old behaviour.
43
+ // A GROUP is left alone: `approveJoins: false` is its operator saying, in
44
+ // as many words, that anyone may join, and mute/eject are the remedy there.
45
+ // A person has no such setting, so this is their default.
46
+ //
47
+ // A gateway-verified Follow (trust mode) is no longer unverifiable — the
48
+ // door proved the sender — so it does not need the OK that unverifiability
49
+ // alone demanded. An explicit `approveJoins` still holds: verified or not,
50
+ // the operator asked to see joins.
51
+ const unverifiedNeedsOk = intake.config.kind !== 'group' && !intake.config.autoAcceptFollows && !trusted;
52
+ const mustApprove = intake.config.approveJoins || unverifiedNeedsOk;
53
+ if (mustApprove && !existing) {
54
+ const reqs = intake.store.getRequests();
55
+ if (!reqs.some(r => r.actor === actor)) {
56
+ reqs.unshift({
57
+ actor, inbox: doc.inbox, sharedInbox: doc.endpoints?.sharedInbox,
58
+ activity: trimActivity(activity), at: new Date().toISOString(),
59
+ });
60
+ intake.store.setRequests(reqs.slice(0, 500));
61
+ intake.store.addNotification({ type: 'follow-request', actor });
62
+ await intake.republish({ pending: true });
63
+ }
64
+ intake.log(`join requested: ${actor}`);
65
+ return;
66
+ }
67
+ if (existing) {
68
+ // Deliberately NOT updating followId. An inbound Follow is unverifiable —
69
+ // that is what the queue above exists for — so letting one rewrite the id
70
+ // of a follower we already hold hands an attacker the exact value onUndo
71
+ // matches on: POST a Follow naming any follower in the published
72
+ // collection, then POST an Undo naming the id you just chose, and they are
73
+ // gone permanently. A genuine refollow needs nothing from us but the
74
+ // Accept below, which is idempotent.
75
+ } else {
76
+ // A Bluesky member who bridges later arrives here as a second, different
77
+ // actor: the bridge follows on their behalf from bsky.brid.gy/ap/<did>,
78
+ // while the native join is recorded under bsky.app/profile/<did>. Left
79
+ // alone that is one person listed twice, carried twice, and ejectable
80
+ // only half at a time. The bridged record supersedes the native one —
81
+ // it reaches the fediverse side, which the native one never could.
82
+ const bridgedDid = /^https:\/\/bsky\.brid\.gy\/ap\/(did:[^/]+)$/.exec(actor)?.[1];
83
+ if (bridgedDid) {
84
+ const before = contacts.followers.length;
85
+ contacts.followers = contacts.followers.filter(f => f.bsky?.did !== bridgedDid);
86
+ if (contacts.followers.length < before) {
87
+ intake.log(`bluesky member ${bridgedDid} is bridged now — the native record gives way to it`);
88
+ }
89
+ }
90
+ contacts.followers.push({
91
+ actor, inbox: doc.inbox, sharedInbox: doc.endpoints?.sharedInbox, followId: activity.id,
92
+ ...(bridgedDid ? { bsky: { did: bridgedDid, bridged: true } } : {}),
93
+ });
94
+ intake.store.setContacts(contacts);
95
+ intake.store.addNotification({ type: 'follow', actor });
96
+ await intake.republish({ followers: true });
97
+ intake.log(`new follower: ${actor}`);
98
+ }
99
+ const { acceptActivity } = await import('../wire.mjs');
100
+ await intake.deliverer.deliver(doc.inbox,
101
+ acceptActivity({ urls: intake.urls, followActivity: activity, serial: intake.serial++ }));
102
+ intake.log(`Accept sent → ${doc.inbox}`);
103
+ }
104
+
105
+ export async function onUndo(intake, activity, actor, { trusted = false } = {}) {
106
+ // AS2 allows `object` to be a bare IRI, and that IRI is exactly the Follow
107
+ // id we stored. Reading `.type` off a string gives undefined, so the whole
108
+ // Undo was dropped — silently, since handle() reads that as handled, so no
109
+ // dead letter was kept and the item was DELETEd. The follower stayed, we
110
+ // kept delivering to them, and their server had recorded the unfollow as
111
+ // done and would never resend. Only a TYPED non-Follow is not ours.
112
+ if (typeof activity.object === 'object' && activity.object?.type
113
+ && activity.object.type !== 'Follow') return;
114
+ // And it must NAME something. Widening the type test to admit a bare IRI
115
+ // also admitted `object: undefined`, `null` and `{}` — which land on the
116
+ // no-followId carve-out below and evict, which is the very hole the
117
+ // followId check was added to close. An Undo that identifies nothing is
118
+ // not an Undo of ours.
119
+ const named = typeof activity.object === 'string' ? activity.object : activity.object?.id;
120
+ // A gateway-verified Undo need not name a stored id — the door proved the
121
+ // sender, so an Undo{Follow} that names us as its object is enough. An
122
+ // unverified one must still identify something (the eviction-hole guard).
123
+ if (!named && !trusted) return;
124
+ // Deliveries arrive unordered: an Undo may land AFTER the refollow it
125
+ // predates. It names the Follow id it revokes — only honor it when it
126
+ // matches the follow we currently hold for that actor.
127
+ const undoneId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
128
+ const contacts = intake.store.getContacts();
129
+ const rec = contacts.followers.find(f => f.actor === actor);
130
+ // Withdrawing a request that was never answered: drop it, or it sits in the
131
+ // operator's queue forever asking about someone who left.
132
+ if (!rec) {
133
+ const reqs = intake.store.getRequests();
134
+ const pending = reqs.find(r => r.actor === actor);
135
+ if (pending) {
136
+ // Bound the same way the follower eviction below is, and for the same
137
+ // reason: `actor` is a field in an unsigned body, so without a match
138
+ // anyone could withdraw anyone else's waiting request. The victim's
139
+ // server believes the Follow is still pending and never resends, so
140
+ // they simply never get followed and nobody sees why.
141
+ const theirs = pending.activity?.id;
142
+ if (!trusted && (!theirs || named !== theirs)) {
143
+ intake.log(`Undo from ${actor} does not name the request we hold — ignored`);
144
+ return;
145
+ }
146
+ intake.store.setRequests(reqs.filter(r => r.actor !== actor));
147
+ await intake.republish({ pending: true });
148
+ intake.log(`join request withdrawn: ${actor}`);
149
+ }
150
+ return;
151
+ }
152
+ // An Undo must NAME the Follow it revokes, and name the one we hold.
153
+ //
154
+ // The follow id is the ONLY thing binding an Undo to the follower. LDN
155
+ // bodies carry no signature, and unlike every other inbound type this path
156
+ // dereferences nothing, so there is no origin to disagree. Matching works
157
+ // because the id was chosen by their server and delivered in a Follow we
158
+ // accepted: we publish the followers collection, but never the ids.
159
+ //
160
+ // Which means a record with NO id cannot be matched at all — and the
161
+ // carve-out that used to let those through turned "we cannot tell" into
162
+ // "anyone may evict". reconcileFollowers writes exactly such records when a
163
+ // restored machine recovers its followers from the pod, so after a restore
164
+ // every follower could be removed by one unauthenticated POST, permanently:
165
+ // dropFollower leaves a mark and the next reconcile will not bring them
166
+ // back, their server recorded no unfollow so it never resends, and neither
167
+ // side has anything to notice.
168
+ //
169
+ // Unmatchable is refused now. The cost is a follower who really did leave
170
+ // staying on the list until the operator ejects them, which is the right way
171
+ // round: `eject` is one command, and the alternative was silent, permanent,
172
+ // and available to anyone.
173
+ // A gateway-verified Undo carries the sender's proof, so it is honored on
174
+ // its own — the followId match below exists only because an UNVERIFIED Undo
175
+ // is otherwise unbindable. A verified one needs no such crutch.
176
+ if (trusted) {
177
+ dropFollower(contacts, actor, 'undo-follow');
178
+ intake.store.setContacts(contacts);
179
+ await intake.republish({ followers: true });
180
+ intake.log(`unfollowed by ${actor} (gateway-verified)`);
181
+ return;
182
+ }
183
+ if (!rec.followId) {
184
+ intake.log(`Undo from ${actor} cannot be matched — this follower was `
185
+ + `${rec.recovered ? 'recovered from the pod' : 'recorded before follow ids were kept'}, `
186
+ + `so its follow id is unknown. Ignored; \`fedipod eject ${actor}\` if they did leave.`);
187
+ return;
188
+ }
189
+ if (undoneId !== rec.followId) {
190
+ intake.log(`Undo from ${actor} does not name the follow we hold `
191
+ + `(revokes ${undoneId || 'nothing'}, current is ${rec.followId}) — ignored`);
192
+ return;
193
+ }
194
+ dropFollower(contacts, actor, 'undo-follow');
195
+ intake.store.setContacts(contacts);
196
+ await intake.republish({ followers: true });
197
+ intake.log(`unfollowed by ${actor}`);
198
+ }
199
+
200
+ export async function onCreate(intake, activity, actor) {
201
+ const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
202
+ if (!objectId) return 'Create without object id';
203
+ if (intake.store.isBlocked(objectId)) return `blocked domain (${objectId})`;
204
+ if (!intake.sameIdentity(objectId, actor)) return `object/actor identity mismatch (${objectId})`;
205
+ // The delivered copy is untrusted for CONTENT, but its addressing is
206
+ // enough to decide whether to bother fetching the origin's copy.
207
+ const envelope = typeof activity.object === 'object' ? { ...activity, ...activity.object } : activity;
208
+ if (!intake.concernsUs(envelope, actor)) return `not addressed to us (${objectId})`;
209
+ // The check onAnnounce has had all along. A re-delivered Create — a remote
210
+ // retry, a group fan-out, or our own sweep seeing an item whose DELETE was
211
+ // refused — cost a fresh signed GET to the origin and rewrote the private
212
+ // RDF note every time, because addStatus only dedupes AFTER the deref.
213
+ // Gated around the INGEST alone: a group must still reach amplify below,
214
+ // which is separately idempotent on announcedAt.
215
+ // Only a status that came from an INGEST counts as already done. TagFeed
216
+ // writes a bare `kind:'tag'` row straight into the index — no pod RDF note,
217
+ // no mention notification, no replies-collection entry — so treating that
218
+ // as ingested loses all three when the same note is then delivered to us.
219
+ const ingested = intake.store.getStatuses()
220
+ .some(x => x.noteId === objectId && (x.kind === 'timeline' || x.kind === 'mention'));
221
+ if (!ingested) {
222
+ const rejected = await intake.ingestNote(objectId, actor);
223
+ if (rejected) return rejected;
224
+ }
225
+ // A group carries its members' posts onward. Only reached from Create, so an
226
+ // inbound Announce is never re-announced. The activity is passed through
227
+ // untouched — FEP-1b12 wants the original wrapped, not a summary of it.
228
+ if (intake.config.kind === 'group') await intake.amplify(objectId, { activity });
229
+ }
230
+
231
+ // A boost: ingest the boosted note when the booster is someone we follow —
232
+ // that's what following means, their boosts widen the timeline. Anything
233
+ // else is unsolicited and only logged.
234
+ // A group we follow announces a Delete: the carrier moderating away a post
235
+ // it carried. Honored only within what the carry itself established — the
236
+ // announcer is a group we follow AND the post reached us via that same
237
+ // group — so no new party is trusted and nothing is dereferenced. Our own
238
+ // posts are never removed by anyone's moderation.
239
+ export async function onAnnouncedDelete(intake, actor, del) {
240
+ const followed = intake.store.getContacts().following.some(f => f.actor === actor && f.accepted);
241
+ if (!followed) { intake.log(`announced Delete from unfollowed ${actor} — ignored`); return; }
242
+ const targetId = typeof del.object === 'string' ? del.object : del.object?.id;
243
+ if (!targetId) return 'announced Delete without an object';
244
+ const s = intake.store.getStatuses().find(x => x.noteId === targetId);
245
+ if (!s || s.kind === 'post' || s.via !== actor) return;
246
+ await intake.forget(s);
247
+ intake.log(`moderated away by ${actor}: ${targetId}`);
248
+ }
249
+
250
+ export async function onAnnounce(intake, activity, actor, objectId) {
251
+ if (!objectId) return 'Announce without object id';
252
+ const followed = intake.store.getContacts().following.some(f => f.actor === actor && f.accepted);
253
+ if (!followed) { intake.log(`Announce from unfollowed ${actor} — ignored`); return; }
254
+ if (intake.store.isBlocked(objectId)) return `blocked domain (${objectId})`;
255
+ const existing = intake.store.getStatuses().find(s => s.noteId === objectId);
256
+ if (existing) {
257
+ // Known, but possibly as a lesser kind — a stranger's mention, a tag or
258
+ // search mirror — none of which the home timeline shows. A carry from
259
+ // someone we follow is exactly what promotes it there.
260
+ if (!['timeline', 'post'].includes(existing.kind)) {
261
+ intake.store.updateStatus(objectId, { kind: 'timeline', via: actor });
262
+ intake.log(`promoted to timeline (carried by ${actor}): ${objectId}`);
263
+ }
264
+ return;
265
+ }
266
+ return intake.ingestNote(objectId, actor, { via: actor });
267
+ }
268
+
269
+ // Mastodon sends these constantly; ignoring them left deleted posts standing
270
+ // for good. Two guards, because a forged Delete would otherwise erase anyone's
271
+ // content: it must come from the object's own origin, and the object must
272
+ // really be gone there. An origin we cannot reach is a retry, never a delete.
273
+ export async function onDelete(intake, activity, actor) {
274
+ const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
275
+ if (!objectId) return 'Delete without object id';
276
+ if (!intake.sameIdentity(objectId, actor)) return `Delete crosses identities (${objectId})`;
277
+ // `objectId === actor` is NOT evidence we care: it is true of EVERY account
278
+ // deletion, and Mastodon broadcasts those constantly. Taking it as known
279
+ // meant a signed dereference to a stranger's server for each one.
280
+ if (!intake.known(objectId)) return; // nothing of ours to remove
281
+ const gone = await intake.isGone(objectId);
282
+ if (gone === null) throw new Error(`cannot confirm ${objectId} is gone — will retry`);
283
+ if (!gone) return `Delete for something still published (${objectId})`;
284
+
285
+ if (objectId === actor) { // the account itself
286
+ const contacts = intake.store.getContacts();
287
+ dropFollower(contacts, actor, 'account-deleted');
288
+ contacts.following = contacts.following.filter(f => f.actor !== actor);
289
+ intake.store.setContacts(contacts);
290
+ // One publish for the lot. Each forget() used to run its own
291
+ // unrecordOutbox, and each of those republished the outbox — so a group
292
+ // that had carried M of this actor's posts paid M full page sweeps for a
293
+ // single inbox item. The Undo deliveries stay per-Announce, because each
294
+ // Announce needs its own; only the pod write is collected.
295
+ const retracted = [];
296
+ for (const s of intake.store.getStatuses().filter(s => s.actor === actor)) {
297
+ await intake.forget(s, { collect: retracted });
298
+ }
299
+ if (retracted.length) {
300
+ const gone = new Set(retracted);
301
+ await intake.publisher.unrecordOutbox(i => gone.has(i?.id));
302
+ }
303
+ // Both: an account deletion drops them from followers AND following.
304
+ await intake.republish({ followers: true, following: true });
305
+ intake.log(`account deleted upstream: ${actor}`);
306
+ return;
307
+ }
308
+ const s = intake.store.getStatuses().find(x => x.noteId === objectId);
309
+ if (s) await intake.forget(s);
310
+ intake.log(`deleted upstream: ${objectId}`);
311
+ }
312
+
313
+ // An edited post, or a changed profile. Verified the only way we can: by
314
+ // refetching at the origin and believing that, not the delivered copy.
315
+ export async function onUpdate(intake, activity, actor) {
316
+ const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
317
+ if (!objectId) return 'Update without object id';
318
+ if (!intake.sameIdentity(objectId, actor)) return `Update crosses identities (${objectId})`;
319
+ if (objectId === actor) { // display name, avatar, bio
320
+ // The same guard onDelete has, for the same reason: the inbox is
321
+ // public-Append, so without it anyone can name any host and make us spend
322
+ // a signed GET on it — and because the failure below THROWS rather than
323
+ // returning a rejection, one planted item buys five of them, plus five
324
+ // pod reads and the head of the inbox held for five sweeps.
325
+ if (!intake.known(actor)) return; // nothing of ours to update
326
+ const doc = await intake.fetchAP(actor);
327
+ if (!doc) throw new Error(`cannot refetch ${actor} — will retry`);
328
+ intake.store.cacheActor(actor, doc); // fetchAP caches Persons; Groups too
329
+ intake.log(`profile updated: ${actor}`);
330
+ return;
331
+ }
332
+ const s = intake.store.getStatuses().find(x => x.noteId === objectId);
333
+ if (!s) return; // not one we hold
334
+ const note = await intake.fetchAP(objectId);
335
+ if (!note) throw new Error(`cannot refetch ${objectId} — will retry`);
336
+ if (note.id !== objectId || !isContentType(note.type)) return `object not verifiable content (${objectId}, ${note.type})`;
337
+ const { attachmentsOf, titledContent } = await import('../wire.mjs');
338
+ const content = titledContent(note);
339
+ const attachments = attachmentsOf(note);
340
+ const freshPoll = pollOf(note);
341
+ const freshEmojis = emojisOf(note);
342
+ intake.store.updateStatus(objectId, {
343
+ content, ...(attachments.length ? { attachments } : {}),
344
+ emojis: freshEmojis.length ? freshEmojis : undefined,
345
+ // The edit's own stamp when the note carries one; tallies and the
346
+ // content warning follow the edit too. A poll refresh keeps our vote.
347
+ editedAt: note.updated || new Date().toISOString(),
348
+ spoiler: note.summary ? String(note.summary).replace(/<[^>]*>/g, '') : undefined,
349
+ ...(freshPoll ? {
350
+ poll: { ...freshPoll, voted: !!s.poll?.voted, ownVotes: s.poll?.ownVotes || [] },
351
+ } : {}),
352
+ });
353
+ intake.log(`edited upstream: ${objectId}`);
354
+ }
355
+
356
+ // The other answer to a Follow, and it was dropped on the floor. Their server
357
+ // has recorded that we do not follow them; ours went on saying we did, and
358
+ // published it — so the two disagreed permanently, and a retry would never
359
+ // come because as far as they are concerned the question was answered.
360
+ //
361
+ // It has to answer the Follow we actually SENT. Only the type was checked, so
362
+ // one Append per account you follow — from anyone, naming no particular
363
+ // follow — severed every one of them at once, and silently: their server
364
+ // never hears about it, so nothing ever retries and nothing looks wrong until
365
+ // the timeline goes quiet. `followActivity` is stored by followActor
366
+ // (lib/social.mjs) for exactly this kind of comparison.
367
+ export async function onReject(intake, activity, actor, { trusted = false } = {}) {
368
+ if (activity.object?.type && activity.object.type !== 'Follow') return;
369
+ const contacts = intake.store.getContacts();
370
+ const rec = contacts.following.find(f => f.actor === actor);
371
+ if (!rec) return; // nothing of ours to undo
372
+ const named = typeof activity.object === 'string' ? activity.object : activity.object?.id;
373
+ const ours = rec.followActivity?.id;
374
+ // A gateway receipt bound to this actor is the other way to believe it —
375
+ // the door checked a signature and said whose (see receiptVouchesFor).
376
+ if (!trusted) {
377
+ if (!ours) {
378
+ intake.log(`Reject from ${actor}: no follow id on record to match it against — ignored`);
379
+ return;
380
+ }
381
+ if (named !== ours) {
382
+ intake.log(`Reject from ${actor} answers ${named || 'nothing'}, not the follow we sent — ignored`);
383
+ return;
384
+ }
385
+ }
386
+ contacts.following = contacts.following.filter(f => f.actor !== actor);
387
+ intake.store.setContacts(contacts);
388
+ await intake.republish({ following: true, pending: true });
389
+ intake.log(`follow rejected by ${actor}`);
390
+ }
391
+
392
+ // Someone we follow has moved. Their server will stop delivering from the old
393
+ // actor, so without this we keep an entry that can never produce another post
394
+ // and never learn where they went. The new account is not followed
395
+ // automatically — that is a Follow only the owner should send — but it is
396
+ // recorded and raised, so it can be acted on.
397
+ export async function onMove(intake, activity, actor) {
398
+ const target = typeof activity.target === 'string' ? activity.target : activity.target?.id;
399
+ if (!target) return 'Move without a target';
400
+ const contacts = intake.store.getContacts();
401
+ const rec = contacts.following.find(f => f.actor === actor);
402
+ if (!rec) return; // not someone we follow
403
+ // Believed only if the actor we follow says so at its OWN origin: a Move is
404
+ // otherwise a redirect anyone could Append.
405
+ const doc = await intake.fetchAP(actor);
406
+ if (!doc) throw new Error(`cannot confirm ${actor} moved — will retry`);
407
+ const movedTo = typeof doc.movedTo === 'string' ? doc.movedTo : doc.movedTo?.id;
408
+ if (movedTo !== target) return `Move not corroborated by ${actor} (says ${movedTo || 'nothing'})`;
409
+ rec.movedTo = target;
410
+ intake.store.setContacts(contacts);
411
+ intake.store.addNotification({ type: 'move', actor, target });
412
+ intake.log(`${actor} moved to ${target} — follow the new account to keep seeing them`);
413
+ }
414
+
415
+ export async function onAccept(intake, activity, actor, { trusted = false } = {}) {
416
+ const contacts = intake.store.getContacts();
417
+ const rec = contacts.following.find(f => f.actor === actor);
418
+ // It has to answer the Follow we actually sent. followActor stores that
419
+ // activity for the later Undo, so the id is here to compare against; without
420
+ // the check any Accept from an actor we happen to follow flips the flag,
421
+ // including one answering a Follow we never made.
422
+ const named = typeof activity.object === 'string' ? activity.object : activity.object?.id;
423
+ const ours = rec?.followActivity?.id;
424
+ // `named &&` used to be part of this, so an Accept naming NOTHING sailed
425
+ // past — which is the easy one to send, and it marks a request to a locked
426
+ // account as accepted when it is still sitting in their queue.
427
+ if (ours && named !== ours && !trusted) {
428
+ intake.log(`Accept from ${actor} answers ${named || 'nothing'}, not the follow we sent — ignored`);
429
+ return;
430
+ }
431
+ if (rec && !rec.accepted) {
432
+ rec.accepted = true;
433
+ intake.store.setContacts(contacts);
434
+ await intake.republish({ following: true, pending: true });
435
+ intake.log(`follow accepted by ${actor}`);
436
+ }
437
+ }