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,288 @@
1
+ // notes.mjs — a note on its way in: whether it concerns us, fetching it at
2
+ // its origin, filing it, the replies collection on our own posts, and the
3
+ // two ways a held post leaves again (forget, retract). Also the §7.1.2
4
+ // forward of a reply into one of our threads to our followers.
5
+
6
+ import * as podNotes from '../../pod/notes.mjs';
7
+ import { PUBLIC } from '../wire.mjs';
8
+ import * as polls from '../polls.mjs';
9
+ import { httpOnly, emojisOf, pollOf, isContentType, authorOf, MAX_MENTIONS, MAX_URL_CHARS, MAX_REPLIES_RECORDED, MAX_FORWARDED, FORWARDABLE, FORWARD_TYPES } from './activity.mjs';
10
+
11
+ // Does this activity/note concern us at all? Either it comes from someone
12
+ // we follow, or it names us (to/cc, mention tag) or replies to one of our
13
+ // notes. Anything else is a stranger blasting inboxes — refuse it before
14
+ // spending a dereference on it.
15
+ export function concernsUs(intake, doc, actor) {
16
+ if (intake.store.getContacts().following.some(f => f.actor === actor && f.accepted)) return true;
17
+ const audience = []
18
+ .concat(doc?.to || [], doc?.cc || [], doc?.bto || [], doc?.bcc || [], doc?.audience || [])
19
+ .map(v => (typeof v === 'string' ? v : v?.id)).filter(Boolean);
20
+ if (audience.includes(intake.urls.actor) || audience.includes(intake.urls.followers)) return true;
21
+ const tagged = [].concat(doc?.tag || [])
22
+ .some(t => t?.type === 'Mention' && (t.href === intake.urls.actor || t.name?.includes(intake.urls.actor)));
23
+ if (tagged) return true;
24
+ const inReplyTo = typeof doc?.inReplyTo === 'string' ? doc.inReplyTo : doc?.inReplyTo?.id;
25
+ if (!inReplyTo) return false;
26
+ if (String(inReplyTo).startsWith(intake.urls.notes)) return true;
27
+ // A group also owns the conversation under anything it carried. Without
28
+ // this, a reply that lost the group's mention on its way round the
29
+ // fediverse is refused, and the thread breaks for everyone who was only
30
+ // ever following the group.
31
+ return intake.config.kind === 'group'
32
+ && intake.store.getStatuses().some(s => s.noteId === String(inReplyTo));
33
+ }
34
+
35
+ // §7.1.2 Forwarding from the inbox. A reply into one of our threads reaches
36
+ // only the servers the replier's server chose to deliver to — never our
37
+ // followers on servers it has never heard of. As the actor those followers
38
+ // follow, WE close that gap: an activity addressed to our followers collection
39
+ // that names one of our objects is re-delivered to our followers' inboxes.
40
+ //
41
+ // Only what was addressed to the followers COLLECTION is carried — bto/bcc are
42
+ // never read here, so a direct message (addressed to a person) never qualifies
43
+ // and is never rebroadcast. And this runs only after handle() accepted the
44
+ // activity, so anything blocked or muted was already refused upstream and is
45
+ // never forwarded.
46
+ export async function maybeForward(intake, activity) {
47
+ if (!activity || typeof activity !== 'object') return;
48
+ if (!FORWARDABLE.has(activity.type)) return; // see FORWARDABLE
49
+ // Forwarding signs somebody else's activity with OUR key and pushes it to
50
+ // every follower we have. That is a lot to do on the word of an unsigned
51
+ // POST, so it is narrowed three ways:
52
+ //
53
+ // 1. Only the types whose object this drain actually DEREFERENCED at the
54
+ // author's own origin. A Create/Update/Delete went through onCreate /
55
+ // onUpdate / onDelete, which fetch and check the author; a Like, an
56
+ // Announce or an Undo is believed on the envelope alone, so relaying
57
+ // one made us a signed relay for anything a stranger cared to write.
58
+ // 2. The actor has to be someone we already know of. A complete stranger
59
+ // addressing our followers collection is not a conversation we are
60
+ // party to.
61
+ // 3. A budget per drain, so a flood cannot turn one sweep into thousands
62
+ // of outbound deliveries under our signature.
63
+ if (!FORWARD_TYPES.has(activity.type)) return;
64
+ try {
65
+ const audience = []
66
+ .concat(activity.to || [], activity.cc || [], activity.audience || [])
67
+ .map(v => (typeof v === 'string' ? v : v?.id)).filter(Boolean);
68
+ if (!audience.includes(intake.urls.followers)) return; // not for our followers
69
+ if (!intake._referencesOurObject(activity)) return; // not into a thread of ours
70
+ const actor = typeof activity.actor === 'string' ? activity.actor : activity.actor?.id;
71
+ if (actor === intake.urls.actor) return; // our own; nothing to forward
72
+ if (!intake.known(actor)) {
73
+ intake.log(`not forwarding ${activity.type} from ${actor}: nobody we know of`);
74
+ return;
75
+ }
76
+ if (intake._forwardBudget <= 0) {
77
+ intake.log(`not forwarding ${activity.type}: this drain's forwarding budget is spent`);
78
+ return;
79
+ }
80
+ const id = typeof activity.id === 'string' ? activity.id : null;
81
+ if (!id) return;
82
+ const forwarded = intake.store.read('forwarded.json', []);
83
+ if (forwarded.includes(id)) return; // already done
84
+
85
+ const inboxes = [...new Set(intake.store.getContacts().followers
86
+ .filter(f => !f.bsky) // Bluesky members are not AP inboxes
87
+ .map(f => f.sharedInbox || f.inbox)
88
+ .filter(Boolean))];
89
+ if (!inboxes.length) return;
90
+ intake._forwardBudget -= 1;
91
+ await intake.deliverer.deliverToAll(inboxes, activity);
92
+ intake.store.write('forwarded.json', [...forwarded, id].slice(-MAX_FORWARDED));
93
+ intake.log(`forwarded ${activity.type} ${id} to ${inboxes.length} follower inbox(es)`);
94
+ } catch (e) {
95
+ intake.log(`inbox forwarding: ${e.message}`); // never stalls the drain
96
+ }
97
+ }
98
+
99
+ // The "objects owned by the server" half of §7.1.2: does the activity reply
100
+ // to, like, boost or otherwise name one of our own objects?
101
+ export function referencesOurObject(intake, activity) {
102
+ const refs = [];
103
+ const add = (v) => { const id = typeof v === 'string' ? v : v?.id; if (id) refs.push(String(id)); };
104
+ const obj = activity.object;
105
+ if (obj && typeof obj === 'object') add(obj.inReplyTo); // a reply's parent
106
+ add(activity.inReplyTo);
107
+ add(activity.object); // a Like/Announce of our note
108
+ add(activity.target);
109
+ if (refs.some(r => r.startsWith(intake.urls.notes))) return true;
110
+ return intake.config.kind === 'group'
111
+ && refs.some(r => intake.store.getStatuses().some(s => s.noteId === r));
112
+ }
113
+
114
+ // Shared tail of Create/Announce: deref the note at its origin (never trust
115
+ // the delivered copy), mirror it into pod RDF + statuses, notify on replies
116
+ // to our own notes. Returns a rejection reason string, or undefined.
117
+ export async function ingestNote(intake, objectId, actor, { via } = {}) {
118
+ const note = await intake.fetchAP(objectId);
119
+ if (!note) return `object fetch failed (${objectId})`;
120
+ if (note.id !== objectId || !isContentType(note.type)) return `object not verifiable content (${objectId}, ${note.type})`;
121
+ const { attachmentsOf, titledContent } = await import('../wire.mjs');
122
+ const attachments = attachmentsOf(note);
123
+ const content = titledContent(note); // hostile markup never reaches pod or client
124
+ // The delivering actor was checked on arrival; the author is only known once
125
+ // the note is dereferenced. authorOf refuses an author the note's own origin
126
+ // does not vouch for — see its comment; this is where a forged attribution
127
+ // would otherwise become a timeline entry, a pod document, and for a group a
128
+ // signed Announce to every member.
129
+ const author = authorOf(note, actor);
130
+ if (!author) return `object names an author its origin does not vouch for (${objectId})`;
131
+ // This is the check that catches a blocked actor reaching us through
132
+ // somebody else's boost, or through a hashtag feed.
133
+ if (intake.store.isBlocked(author)) return `blocked author (${author})`;
134
+
135
+ // The ENVELOPE said this concerns us. The envelope is a document a stranger
136
+ // wrote: onCreate reads addressing off the delivered copy to decide whether
137
+ // fetching is worth it, and nothing re-asked the question of the copy that
138
+ // came back from the author's own server. So anyone could take any public
139
+ // post, address the delivery to us, and have it filed as "X mentioned you"
140
+ // — or, to a group, have a member's post carried to every follower when the
141
+ // member never sent it there.
142
+ //
143
+ // A boost is exempt (`via`): someone we follow deliberately putting a post
144
+ // in front of us is the whole point, and the note will not address us.
145
+ if (!via && !intake.concernsUs(note, author)) {
146
+ return `the note its own server serves does not address us (${objectId})`;
147
+ }
148
+
149
+ // An answer to one of our polls is a number on a document, not a post. It
150
+ // arrives as an ordinary reply naming an option and carrying nothing else,
151
+ // so filing it as one would put a blank entry in the thread and ring the
152
+ // owner once per voter. Counted or refused — a second answer, an option we
153
+ // do not offer, a poll already shut — it stops here either way.
154
+ const asked = note.inReplyTo && intake.store.getStatuses()
155
+ .find(x => x.noteId === String(note.inReplyTo) && x.kind === 'post' && x.poll);
156
+ if (asked && polls.isVoteShape(note)) {
157
+ const counted = await intake.publisher.recordVote(asked.noteId, author, note.name)
158
+ .catch(e => { intake.log(`vote on ${asked.noteId}: ${e.message}`); return false; });
159
+ intake.log(counted
160
+ ? `vote counted (${note.name}): ${asked.noteId}`
161
+ : `vote not counted (${note.name}) from ${author}: ${asked.noteId}`);
162
+ return;
163
+ }
164
+
165
+ // Anyone can Append to a public inbox, so arriving is not the same as
166
+ // belonging in the home timeline. Follow Mastodon's split: people you
167
+ // follow (and their boosts) are HOME; anyone else is a MENTION — kept,
168
+ // notified, readable in the Mentions view, but out of the timeline, and
169
+ // mirror-only so unsolicited content never accumulates in the pod.
170
+ // A group's people are its FOLLOWERS — it follows nobody. Reading the
171
+ // following list for one filed every member's post as a stranger's mention,
172
+ // so nothing reached the pod RDF and each post raised a notification.
173
+ const contacts = intake.store.getContacts();
174
+ const known = intake.config.kind === 'group'
175
+ ? contacts.followers.some(f => f.actor === author)
176
+ : contacts.following.some(f => f.actor === author && f.accepted);
177
+ // Someone in a group you are in is not a stranger: their reply belongs in
178
+ // the room, not in the drawer of unsolicited mail. Whose word this is on
179
+ // is the group's — its published membership — so the group's own door
180
+ // decides who gets in.
181
+ const followed = via || known || (!known && await intake.isCoMember(author));
182
+ const kind = followed ? 'timeline' : 'mention';
183
+
184
+ // Mastodon carries a thread's mentions into every reply, which is the only
185
+ // reason a reply ever reaches a group. Keep them so our composer can too.
186
+ const mentions = [].concat(note.tag || [])
187
+ .filter(t => t?.type === 'Mention' && t.href && t.name)
188
+ .slice(0, MAX_MENTIONS)
189
+ .map(t => ({ href: httpOnly(String(t.href).slice(0, MAX_URL_CHARS)), name: String(t.name).slice(0, 256) }))
190
+ .filter(m => m.href);
191
+ const emojis = emojisOf(note);
192
+ const poll = pollOf(note);
193
+ // Explicitly addressed, but to nobody public and to no followers
194
+ // collection: a direct message, which belongs to the conversations view
195
+ // rather than a timeline. A note with no addressing at all is NOT direct —
196
+ // some servers omit to/cc, and vanishing from home is the wrong reading.
197
+ const audience = [].concat(note.to || [], note.cc || []).map(String);
198
+ const direct = audience.length > 0
199
+ && !audience.includes(PUBLIC) && !audience.some(a => a.endsWith('/followers'));
200
+ // Addressed to less than the world: whatever else happens to it, a group
201
+ // must never widen its audience by carrying it.
202
+ const nonPublic = audience.length > 0 && !audience.includes(PUBLIC);
203
+ intake.store.addStatus({
204
+ noteId: note.id, actor: author, content,
205
+ published: note.published, inReplyTo: note.inReplyTo, kind,
206
+ ...(direct ? { direct: true } : {}),
207
+ ...(nonPublic ? { nonPublic: true } : {}),
208
+ // The author's content warning, shown as one: plain text only.
209
+ ...(note.summary ? { spoiler: String(note.summary).replace(/<[^>]*>/g, '') } : {}),
210
+ ...(poll ? { poll } : {}),
211
+ ...(emojis.length ? { emojis } : {}),
212
+ ...(mentions.length ? { mentions } : {}),
213
+ ...(attachments.length ? { attachments } : {}),
214
+ ...(via ? { via } : {}),
215
+ });
216
+ if (!followed || (note.inReplyTo && String(note.inReplyTo).startsWith(intake.urls.notes))) {
217
+ intake.store.addNotification({ type: 'mention', actor: author, noteId: note.id });
218
+ }
219
+ if (note.inReplyTo && String(note.inReplyTo).startsWith(intake.urls.notes)) {
220
+ await intake.addReply(String(note.inReplyTo), note.id)
221
+ .catch(e => intake.log(`replies collection: ${e.message}`));
222
+ }
223
+ intake.log(`${kind}: ${note.id}${via ? ` (boosted by ${via})` : ''}`);
224
+ }
225
+
226
+ // Drop a post we were holding. A group that carried it also unsays its own
227
+ // Announce — forwarding the author's Delete would be signed by us and not by
228
+ // them, which receivers are right to refuse.
229
+ // `collect` batches the outbox side: retract pushes the Announce id onto it
230
+ // instead of republishing, and the caller writes once for all of them.
231
+ export async function forget(intake, s, { collect = null } = {}) {
232
+ if (s.announceActivity) {
233
+ await intake.retract(s.noteId, { collect }).catch(e => intake.log(`retract: ${e.message}`));
234
+ }
235
+ intake.store.removeStatus(s.noteId);
236
+ }
237
+
238
+ // Undo an Announce this group made. Shared with the operator's `retract`.
239
+ export async function retract(intake, noteId, { collect = null } = {}) {
240
+ const s = intake.store.getStatuses().find(x => x.noteId === noteId);
241
+ if (!s) throw new Error('no such post');
242
+ // A Bluesky carry is a repost, and unsaying it is deleting the repost.
243
+ if (s.repostUri) {
244
+ if (!intake.bskyGroup) throw new Error('no bluesky account connected');
245
+ return intake.bskyGroup.retract(s);
246
+ }
247
+ if (!s.announceActivity) throw new Error('that post was never carried');
248
+ const { undoActivity } = await import('../wire.mjs');
249
+ const inboxes = intake.announceTargets(s.actor);
250
+ await intake.deliverer.deliverToAll(inboxes,
251
+ undoActivity({ urls: intake.urls, activity: s.announceActivity, serial: intake.serial++ }));
252
+ if (collect) collect.push(s.announceActivity.id);
253
+ else await intake.publisher.unrecordOutbox(i => i?.id === s.announceActivity.id);
254
+ intake.store.updateStatus(noteId, {
255
+ announcedAt: undefined, announceActivity: undefined, retractedAt: new Date().toISOString(),
256
+ });
257
+ return { ok: true, noteId, inboxes: inboxes.length };
258
+ }
259
+
260
+ // Read-modify-write, and the drain is serialized, so two replies in one sweep
261
+ // do not race. Nothing else writes this document.
262
+ // MAX_REPLIES_RECORDED caps the collection. It is a discovery aid — a client
263
+ // reading a thread — and the statuses index is what actually holds the
264
+ // replies, so dropping the oldest costs a hop, not a record.
265
+ export async function addReply(intake, parentId, replyId) {
266
+ // The parent has to be a post we actually made. The only check used to be
267
+ // that the id started with our notes prefix, and `inReplyTo` is read off a
268
+ // document at the sender's own origin — so a stranger could name a note we
269
+ // never wrote, and we would CREATE a document on the pod at a URL of their
270
+ // choosing and then grow it, one whole re-PUT per reply, with no cap. Four
271
+ // pod requests each and bytes quadratic in the number of replies.
272
+ if (!intake.store.getStatuses().some(s => s.noteId === parentId && s.kind === 'post')) {
273
+ intake.log(`reply names ${parentId}, which is not a post of ours — not recorded`);
274
+ return;
275
+ }
276
+ const { repliesId, collection } = await import('../wire.mjs');
277
+ const url = repliesId(parentId);
278
+ // Deliberately NOT caught: a read we could not make is not an empty
279
+ // collection, and rewriting on top of one erases every reply already
280
+ // recorded. getJson returns null only for a genuine 404 — the document does
281
+ // not exist yet — and throws otherwise, which the caller logs and retries.
282
+ const cur = await podNotes.readReplies(intake.remote, url);
283
+ const items = Array.isArray(cur?.items) ? cur.items : [];
284
+ if (items.includes(replyId)) return;
285
+ items.push(replyId);
286
+ await podNotes.writeReplies(intake.remote, url, collection(url, items.slice(-MAX_REPLIES_RECORDED)));
287
+ intake.log(`reply recorded on ${parentId}`);
288
+ }
@@ -0,0 +1,142 @@
1
+ // verify.mjs — how the drain decides what to believe: a signed fetch of the
2
+ // claimed document at its own origin, what local state already knows of, and
3
+ // the receipt a gateway left beside a delivery.
4
+
5
+ import * as podInbox from '../../pod/inbox.mjs';
6
+ import { readCapped } from '../../shared/safefetch.mjs';
7
+ import { isActorType, sameOrigin, ACCEPT_AP } from './activity.mjs';
8
+ import { readLenient } from '../as2.mjs';
9
+
10
+ export async function fetchAP(intake, url) {
11
+ const res = await intake.deliverer.signedFetch(url, { headers: { accept: ACCEPT_AP } });
12
+ if (res.status >= 400) return null;
13
+ // Remote servers are untrusted: read with a byte budget rather than
14
+ // letting res.json() buffer whatever they choose to send.
15
+ const { readCapped } = await import('../../shared/safefetch.mjs');
16
+ // Plenty of servers answer 200 text/html however politely we ask for AS2 —
17
+ // people reply to ordinary web pages, and their id is that page. Say so,
18
+ // rather than handing the HTML to JSON.parse and logging the parser's
19
+ // complaint about an unexpected `<`.
20
+ // Not logged: people reply to ordinary web pages, so the reply's object id
21
+ // is that page and this is the expected answer, not a fault. Only a server
22
+ // that CLAIMS to be sending JSON and then does not is worth a line.
23
+ const ct = (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
24
+ if (ct && !ct.endsWith('json')) return null;
25
+ let doc = null;
26
+ try {
27
+ const read = await readLenient(await readCapped(res));
28
+ if (read.degraded) intake.log(`fetch ${url} grounded to read: ${read.degraded}`);
29
+ doc = read.view ?? read.doc;
30
+ } catch (e) { intake.log(`fetch ${url}: unreadable — ${e.message}`); return null; }
31
+ if (!doc) { intake.log(`fetch ${url}: unreadable as JSON`); return null; }
32
+ // A document is only evidence about its OWN origin.
33
+ //
34
+ // signedFetch follows redirects and hands back only the final response, and
35
+ // every caller checks `doc.id` against the URL it ASKED for — so an open
36
+ // redirector on a victim's origin let an attacker's server answer for a
37
+ // victim's URL and be believed by that check.
38
+ //
39
+ // Only a redirect that CROSSED AN ORIGIN is refused here. A same-origin
40
+ // redirect is ordinary (canonicalisation, trailing slashes), and a document
41
+ // fetched with no redirect at all is still returned whatever it claims —
42
+ // the callers reject it on id, and they say something more useful about it
43
+ // than this could ("actor id mismatch", "object not verifiable content").
44
+ const landed = res.finalUrl || url;
45
+ if (!sameOrigin(landed, url) && doc && doc.id && !sameOrigin(doc.id, landed)) {
46
+ intake.log(`fetch ${url}: redirected to ${landed}, which is not where ${doc.id} lives — refused`);
47
+ return null;
48
+ }
49
+ // Every actor type, not just Person. A Group was fetched, used and thrown
50
+ // away, so nothing knew its preferredUsername — and a client rendering it
51
+ // fell back to the last path segment of the actor URL, which is the literal
52
+ // word `actor`. That is where @actor@host came from.
53
+ // Under the id the document CLAIMS, but only when its own origin vouches
54
+ // for that id. A stranger's actor document naming someone else's id used to
55
+ // overwrite that actor's cached name, bio, avatar and Person/Group flag —
56
+ // one appended Follow was enough, and the id-mismatch checks in onFollow
57
+ // and ingestNote both run after this line and never undid it.
58
+ //
59
+ // Same origin rather than exact equality: signedFetch follows redirects
60
+ // without reporting where it landed, so a server that redirects its own
61
+ // canonical actor URL would otherwise stop being cached at all.
62
+ if (isActorType(doc?.type) && doc.id && sameOrigin(doc.id, url)) {
63
+ intake.store.cacheActor(doc.id, doc);
64
+ }
65
+ return doc;
66
+ }
67
+
68
+ // Have we ever heard of this actor or object? Answered entirely from local
69
+ // state, so asking costs nothing. It is what stops a stranger's Delete or
70
+ // Update — of which Mastodon broadcasts a great many, and of which anyone at
71
+ // all can Append one — turning into a signed request to a host they chose.
72
+ export function known(intake, id) {
73
+ const c = intake.store.getContacts();
74
+ return c.followers.some(f => f.actor === id)
75
+ || c.following.some(f => f.actor === id)
76
+ || intake.store.getStatuses().some(s => s.noteId === id || s.actor === id)
77
+ || !!intake.store.getActors()[id];
78
+ }
79
+
80
+ // Returns a rejection reason string, or undefined when handled.
81
+ // The gateway's shared HMAC secret, or null when no gateway is configured.
82
+ // Its absence is what makes the whole receipt path dormant by default.
83
+ export function gatewaySecret(intake) {
84
+ return intake.store.getConfig()?.gateway?.hmacSecret || null;
85
+ }
86
+
87
+ // Shadow-mode measurement: how much real traffic actually verified. The one
88
+ // number an operator needs before trusting receipts. Only touched while a
89
+ // gateway is configured, so it costs a non-gateway install nothing.
90
+ export function bumpGatewayStat(intake, verified) {
91
+ const s = intake.store.read('gateway-stats.json', { verified: 0, unverified: 0 });
92
+ if (verified) s.verified++; else s.unverified++;
93
+ s.lastAt = new Date().toISOString();
94
+ intake.store.write('gateway-stats.json', s);
95
+ }
96
+
97
+ // Read and authenticate the receipt a gateway wrote beside an inbox item.
98
+ // Returns the receipt object only when its HMAC verifies against our secret;
99
+ // null otherwise (no gateway, no receipt, a stranger's forged one, or a read
100
+ // failure) — and null means "unverified", the pre-gateway behavior.
101
+ export async function readReceipt(intake, itemUrl) {
102
+ const secret = intake.gatewaySecret();
103
+ if (!secret) return null;
104
+ try {
105
+ const { readCapped: cap } = await import('../../shared/safefetch.mjs');
106
+ const receipt = await podInbox.readDeliveryReceipt(intake.remote, itemUrl, { maxBytes: 64 * 1024, readCapped: cap });
107
+ if (!receipt) return null;
108
+ const { verifyReceipt } = await import('../../gateway/httpsig.mjs');
109
+ return verifyReceipt(receipt, secret) ? receipt : null;
110
+ } catch { return null; }
111
+ }
112
+
113
+ // Whether a receipt says anything about THIS actor. Verified-and-about-someone
114
+ // -else is worth exactly as much as unverified, and is treated the same way:
115
+ // the drain's verify-by-dereference still stands behind it.
116
+ export function receiptVouchesFor(intake, receipt, actor) {
117
+ if (!receipt?.verified) return false;
118
+ if (!receipt.actor || receipt.actor !== actor) return false;
119
+ // A missing keyId with verified:true cannot come from our own door
120
+ // (makeReceipt fills both from the same key), so refuse rather than waive.
121
+ if (!receipt.keyId || !sameOrigin(receipt.keyId, actor)) return false;
122
+ return true;
123
+ }
124
+
125
+ // Is this really gone at its origin? true / false / null when the origin
126
+ // could not be asked. Delivered bodies carry no signature, so this is how a
127
+ // Delete is verified — the same verify-by-dereference the rest of intake uses.
128
+ export async function isGone(intake, url) {
129
+ let res;
130
+ try { res = await intake.deliverer.signedFetch(url, { headers: { accept: ACCEPT_AP } }); }
131
+ catch { return null; }
132
+ if (res.status === 404 || res.status === 410) return true;
133
+ if (res.status < 400) {
134
+ // A Tombstone answers 200 and still means deleted.
135
+ try {
136
+ const { readCapped } = await import('../../shared/safefetch.mjs');
137
+ const read = await readLenient(await readCapped(res));
138
+ return (read.view ?? read.doc)?.type === 'Tombstone';
139
+ } catch { return false; }
140
+ }
141
+ return null; // 401/403/5xx — no answer, not a denial
142
+ }
@@ -12,7 +12,7 @@
12
12
  import crypto from 'node:crypto';
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
- import { writeJsonAtomic } from './home.mjs';
15
+ import { writeJsonAtomic } from '../device/home.mjs';
16
16
 
17
17
  const RSA_ALG = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
18
18