fedipod 1.0.0 → 1.3.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 (151) hide show
  1. package/README.md +4 -4
  2. package/bin/fedipod.mjs +45 -2253
  3. package/gateway.md +1 -1
  4. package/lib/{c2s.mjs → client/c2s.mjs} +10 -3
  5. package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
  6. package/lib/client/masto/accounts.mjs +264 -0
  7. package/lib/client/masto/body.mjs +69 -0
  8. package/lib/client/masto/index.mjs +183 -0
  9. package/lib/client/masto/instance.mjs +104 -0
  10. package/lib/client/masto/media.mjs +133 -0
  11. package/lib/client/masto/oauth.mjs +599 -0
  12. package/lib/client/masto/render.mjs +459 -0
  13. package/lib/client/masto/statuses.mjs +331 -0
  14. package/lib/client/masto/timelines.mjs +316 -0
  15. package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
  16. package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
  17. package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
  18. package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
  19. package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
  20. package/lib/{import.mjs → connections/import.mjs} +1 -1
  21. package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +3 -3
  22. package/lib/connections/vault.mjs +114 -0
  23. package/lib/core/as2.mjs +170 -0
  24. package/lib/core/contexts/activitystreams.json +379 -0
  25. package/lib/core/contexts/did-v1.json +57 -0
  26. package/lib/core/contexts/fep-5711.json +36 -0
  27. package/lib/core/contexts/gotosocial.json +86 -0
  28. package/lib/core/contexts/identity-v1.json +152 -0
  29. package/lib/core/contexts/index.mjs +45 -0
  30. package/lib/core/contexts/join-lemmy.json +33 -0
  31. package/lib/core/contexts/joinmastodon.json +28 -0
  32. package/lib/core/contexts/map.json +16 -0
  33. package/lib/core/contexts/miscellany.json +19 -0
  34. package/lib/core/contexts/schemaorg.json +8845 -0
  35. package/lib/core/contexts/security-data-integrity-v1.json +78 -0
  36. package/lib/core/contexts/security-data-integrity-v2.json +81 -0
  37. package/lib/core/contexts/security-multikey-v1.json +35 -0
  38. package/lib/core/contexts/security-v1.json +74 -0
  39. package/lib/core/contexts/webfinger.json +10 -0
  40. package/lib/{deliver.mjs → core/deliver.mjs} +2 -2
  41. package/lib/core/graphview.mjs +269 -0
  42. package/lib/core/intake/activities.mjs +437 -0
  43. package/lib/core/intake/activity.mjs +240 -0
  44. package/lib/core/intake/channel.mjs +144 -0
  45. package/lib/core/intake/group.mjs +222 -0
  46. package/lib/core/intake/index.mjs +629 -0
  47. package/lib/core/intake/notes.mjs +288 -0
  48. package/lib/core/intake/verify.mjs +142 -0
  49. package/lib/{keys.mjs → core/keys.mjs} +1 -1
  50. package/lib/core/publisher/collections.mjs +229 -0
  51. package/lib/core/publisher/index.mjs +421 -0
  52. package/lib/core/publisher/notes.mjs +188 -0
  53. package/lib/core/publisher/questions.mjs +233 -0
  54. package/lib/core/publisher/restore.mjs +199 -0
  55. package/lib/core/shapes/activitystreams.ttl +129 -0
  56. package/lib/core/shapes/index.mjs +107 -0
  57. package/lib/core/shapes/shapes-text.mjs +13 -0
  58. package/lib/{social.mjs → core/social.mjs} +2 -2
  59. package/lib/{store.mjs → core/store.mjs} +4 -0
  60. package/lib/{wire.mjs → core/wire.mjs} +2 -2
  61. package/lib/device/admin/index.mjs +13 -0
  62. package/lib/device/admin/origins.mjs +35 -0
  63. package/lib/device/admin/routes/connections.mjs +144 -0
  64. package/lib/device/admin/routes/gateway.mjs +199 -0
  65. package/lib/device/admin/routes/lifecycle.mjs +191 -0
  66. package/lib/device/admin/routes/owner.mjs +322 -0
  67. package/lib/device/admin/routes/setup.mjs +393 -0
  68. package/lib/device/admin/routes/social.mjs +188 -0
  69. package/lib/device/admin/server.mjs +95 -0
  70. package/lib/device/admin/static.mjs +244 -0
  71. package/lib/device/admin/surface.mjs +274 -0
  72. package/lib/device/cli/commands/account.mjs +586 -0
  73. package/lib/device/cli/commands/run.mjs +278 -0
  74. package/lib/device/cli/commands/service.mjs +221 -0
  75. package/lib/device/cli/commands/setup.mjs +410 -0
  76. package/lib/device/cli/commands/state.mjs +559 -0
  77. package/lib/device/cli/context.mjs +288 -0
  78. package/lib/{migrate.mjs → device/migrate.mjs} +1 -1
  79. package/lib/{remote.mjs → device/remote.mjs} +3 -3
  80. package/lib/{setup.mjs → device/setup.mjs} +3 -3
  81. package/lib/{update.mjs → device/update.mjs} +1 -1
  82. package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
  83. package/lib/{front-core.mjs → gateway/front-core.mjs} +3 -3
  84. package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +1 -1
  85. package/lib/{httpsig.mjs → gateway/httpsig.mjs} +1 -1
  86. package/lib/server/embed.mjs +405 -0
  87. package/lib/{links.mjs → shared/links.mjs} +1 -1
  88. package/lib/{ua.mjs → shared/ua.mjs} +1 -1
  89. package/package.json +11 -1
  90. package/run-agent.mjs +33 -25
  91. package/scripts/build-app.mjs +4 -0
  92. package/scripts/check-pod-calls.mjs +8 -2
  93. package/scripts/refresh-contexts.mjs +39 -0
  94. package/web/admin/actors.js +145 -0
  95. package/web/admin/common.js +23 -0
  96. package/web/admin/connections.js +112 -0
  97. package/web/admin/gateway.js +111 -0
  98. package/web/admin/group.js +258 -0
  99. package/web/admin/index.html +7 -1
  100. package/web/admin/record.js +378 -0
  101. package/web/admin/setup/index.html +1 -0
  102. package/web/admin/setup/setup.js +2 -13
  103. package/web/admin/upkeep.js +170 -0
  104. package/web/app/README.md +6 -6
  105. package/web/app/admin-facade.mjs +3 -3
  106. package/web/app/agent.mjs +14 -16
  107. package/web/app/atproto-browser.mjs +1 -1
  108. package/web/app/boot.mjs +2 -3
  109. package/web/app/deliver-relay.mjs +1 -1
  110. package/web/app/dist/boot.js +22 -3
  111. package/web/app/dist/boot.js.map +2 -2
  112. package/web/app/dist/sw.js +21913 -5446
  113. package/web/app/dist/sw.js.map +4 -4
  114. package/web/app/fediacct-browser.mjs +1 -1
  115. package/web/app/keys-browser.mjs +27 -4
  116. package/web/app/shims/shapes-text.mjs +8 -0
  117. package/web/app/signup.mjs +2 -3
  118. package/web/app/site/admin/actors.js +145 -0
  119. package/web/app/site/admin/common.js +23 -0
  120. package/web/app/site/admin/connections.js +112 -0
  121. package/web/app/site/admin/gateway.js +111 -0
  122. package/web/app/site/admin/group.js +258 -0
  123. package/web/app/site/admin/index.html +7 -1
  124. package/web/app/site/admin/record.js +378 -0
  125. package/web/app/site/admin/setup/index.html +1 -0
  126. package/web/app/site/admin/setup/setup.js +2 -13
  127. package/web/app/site/admin/upkeep.js +170 -0
  128. package/web/app/site/boot.js +22 -3
  129. package/web/app/site/sw.js +21913 -5446
  130. package/web/app/sw-src.mjs +17 -2
  131. package/lib/admin.mjs +0 -1913
  132. package/lib/embed.mjs +0 -220
  133. package/lib/intake.mjs +0 -1981
  134. package/lib/mastoapi.mjs +0 -2284
  135. package/lib/publisher.mjs +0 -1192
  136. package/web/admin/admin.js +0 -1181
  137. package/web/app/site/admin/admin.js +0 -1181
  138. /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
  139. /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
  140. /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
  141. /package/lib/{lease.mjs → core/lease.mjs} +0 -0
  142. /package/lib/{polls.mjs → core/polls.mjs} +0 -0
  143. /package/lib/{proof.mjs → core/proof.mjs} +0 -0
  144. /package/lib/{storage.mjs → core/storage.mjs} +0 -0
  145. /package/lib/{account.mjs → device/account.mjs} +0 -0
  146. /package/lib/{certs.mjs → device/certs.mjs} +0 -0
  147. /package/lib/{export-collections.mjs → device/export-collections.mjs} +0 -0
  148. /package/lib/{home.mjs → device/home.mjs} +0 -0
  149. /package/lib/{ports.mjs → device/ports.mjs} +0 -0
  150. /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
  151. /package/lib/{safefetch.mjs → shared/safefetch.mjs} +0 -0
@@ -0,0 +1,229 @@
1
+ // collections.mjs — the actor's published collections: the paged outbox and
2
+ // followers, following, featured, and the owner-only pending and blocked
3
+ // lists; and the outbox record a post or a boost goes into.
4
+
5
+ import crypto from 'node:crypto';
6
+ import * as wire from '../wire.mjs';
7
+ import * as podOutbox from '../../pod/outbox.mjs';
8
+ import * as podFollowers from '../../pod/followers.mjs';
9
+ import * as podFollowing from '../../pod/following.mjs';
10
+ import * as podFeatured from '../../pod/featured.mjs';
11
+ import * as podPrivate from '../../pod/private.mjs';
12
+
13
+ // The default for publishCollections: the whole public surface, ACLs included.
14
+ // A caller that knows what it changed narrows it; a caller that says nothing
15
+ // still gets everything, so a missed call site degrades to the old cost rather
16
+ // than silently publishing nothing.
17
+ export const ALL_COLLECTIONS = { followers: true, following: true, outbox: true, acls: true,
18
+ pending: true, blocked: true };
19
+
20
+ // Publish the collections a change actually TOUCHED.
21
+ //
22
+ // Publishing all three on every follower event cost nine pod requests where
23
+ // two do: two reconcile reads, three collection PUTs, and three ACL PUTs
24
+ // whose bodies are a pure function of the WebID and the target URL and so
25
+ // are byte-identical to the ones written at setup. A new follower does not
26
+ // change what this actor follows, and it does not change the outbox.
27
+ //
28
+ // `acls` is true only on the default path, which is publishProfile: that is
29
+ // where the public surface is built, and where verifyPublicSurface already
30
+ // checks the world can read it.
31
+ //
32
+ // Reconciliation stays welded to the collection it guards. It is what stops a
33
+ // restored-and-behind machine publishing a short list over the pod's longer
34
+ // one — erasing followers it would then stop delivering to, and erasing the
35
+ // outbox that `rebuild` reads as its index — so a narrowed publish still runs
36
+ // the one belonging to whatever it is about to overwrite.
37
+ // Publish only the pages that actually changed.
38
+ //
39
+ // `known` is what we last wrote, so a post rewrites the newest page and the
40
+ // head and nothing else — where the flat collection rewrote the actor's whole
41
+ // history on every post. A page gets its ACL when it is first created; the
42
+ // container above it is owner-only, so it cannot be inherited.
43
+ // `force` is for the caller that publishes BECAUSE the pod does not have what
44
+ // the digests say it has. Without it a repair republish rewrote the head and
45
+ // skipped every page — the digests still matched the local record — so the
46
+ // head advertised a `first:` that 404s, readPublishedOutbox came back empty,
47
+ // rebuildStatuses recovered nothing, and the whole thing logged success. That
48
+ // happens to an actor with one post as surely as one with five thousand.
49
+ export async function publishOutbox(publisher, outbox, { acls = false, force = false } = {}) {
50
+ const { urls } = publisher;
51
+ const seen = publisher.store.read('published.json', {});
52
+ const { pages, index } = wire.outboxPaging(outbox, seen.outboxIndex || []);
53
+ const before = force ? {} : (seen.outboxPages || {});
54
+ const after = {};
55
+ let wrote = 0;
56
+
57
+ for (let i = 0; i < pages.length; i++) {
58
+ const n = i + 1; // 1 = oldest
59
+ const doc = wire.outboxPage(urls.outbox, n, pages[i]);
60
+ const digest = crypto.createHash('sha256').update(JSON.stringify(doc)).digest('hex').slice(0, 16);
61
+ after[n] = digest;
62
+ if (before[n] === digest) continue; // sealed and unchanged
63
+ await podOutbox.writePage(publisher.remote, wire.outboxPageId(urls.outbox, n), doc,
64
+ { publicRead: !before[n] || acls });
65
+ wrote++;
66
+ }
67
+ // Pages above the new count are orphans: the outbox shrank past them, and
68
+ // left where they were they keep serving activities that have been taken
69
+ // back. The head no longer points at them, so nothing walks to them — but
70
+ // the URL is guessable and public.
71
+ const stale = Object.keys(seen.outboxPages || {}).map(Number)
72
+ .filter(n => Number.isFinite(n) && n > pages.length);
73
+ for (const n of stale) {
74
+ await podOutbox.dropPage(publisher.remote, wire.outboxPageId(urls.outbox, n));
75
+ }
76
+ // The head carries totalItems, so it moves whenever the outbox does. Four
77
+ // lines, and constant however much you have posted.
78
+ await podOutbox.writeHead(publisher.remote, urls,
79
+ wire.outboxHead(urls.outbox, outbox.length, pages.length), { publicRead: acls });
80
+ publisher.store.write('published.json',
81
+ { ...publisher.store.read('published.json', {}), outboxPages: after, outboxIndex: index });
82
+ return wrote;
83
+ }
84
+
85
+ // Every activity in the published outbox, walking the pages. Also understands
86
+ // the flat collection this used to write, so an actor published before paging
87
+ // is still readable — which matters because rebuild reads this to recover
88
+ // posts a lost machine no longer has.
89
+ export function readPublishedOutbox(publisher) { return podOutbox.readPublished(publisher.remote, publisher.urls); }
90
+
91
+ // The followers collection, paged like the outbox: a head that carries only
92
+ // the count and the page bounds, and page documents holding the actor IRIs —
93
+ // so a remote server reads a small head and walks pages instead of pulling one
94
+ // document that grows without limit. Regenerated from the in-memory follow
95
+ // graph: a follow extends the newest page and an unfollow leaves its page one
96
+ // short (wire.pageItems), so only the pages that changed are rewritten.
97
+ export async function publishFollowers(publisher, actors, { acls = false, force = false } = {}) {
98
+ const { urls } = publisher;
99
+ const seen = publisher.store.read('published.json', {});
100
+ const { pages, index } = wire.followersPaging(actors, seen.followersIndex || []);
101
+ const before = force ? {} : (seen.followersPages || {});
102
+ const after = {};
103
+
104
+ for (let i = 0; i < pages.length; i++) {
105
+ const n = i + 1;
106
+ const doc = wire.followersPage(urls.followers, n, pages[i], pages.length);
107
+ const digest = crypto.createHash('sha256').update(JSON.stringify(doc)).digest('hex').slice(0, 16);
108
+ after[n] = digest;
109
+ if (before[n] === digest) continue; // unchanged page
110
+ await podFollowers.writePage(publisher.remote, wire.followersPageId(urls.followers, n), doc,
111
+ { publicRead: !before[n] || acls });
112
+ }
113
+ // Pages the collection shrank past would keep serving names that no longer
114
+ // follow; the head stops pointing at them but the URL is guessable.
115
+ const stale = Object.keys(seen.followersPages || {}).map(Number)
116
+ .filter(n => Number.isFinite(n) && n > pages.length);
117
+ for (const n of stale) {
118
+ await podFollowers.dropPage(publisher.remote, wire.followersPageId(urls.followers, n));
119
+ }
120
+ await podFollowers.writeHead(publisher.remote, urls,
121
+ wire.followersHead(urls.followers, actors.length, pages.length), { publicRead: acls });
122
+ publisher.store.write('published.json',
123
+ { ...publisher.store.read('published.json', {}), followersPages: after, followersIndex: index });
124
+ }
125
+
126
+ // Every actor in the published followers collection, walking pages. Also reads
127
+ // the flat collection this used to write, so an actor published before paging
128
+ // still reconciles.
129
+ export function readPublishedFollowers(publisher) { return podFollowers.readPublished(publisher.remote, publisher.urls); }
130
+
131
+ export async function publishCollections(publisher, which = ALL_COLLECTIONS) {
132
+ const { urls } = publisher;
133
+ const contacts = publisher.store.getContacts();
134
+ if (which.followers) {
135
+ // Reconcile walks the published pages, so — like the outbox — pay for it
136
+ // only when the local record of what is up there is missing (a restore or
137
+ // a copied machine), not on every ordinary save.
138
+ const knownF = publisher.store.read('published.json', {}).followersIndex;
139
+ if (which.force || !Array.isArray(knownF)) await publisher.reconcileFollowers(contacts);
140
+ // Bluesky-only members are not AP actors; the published collection
141
+ // lists only what a remote server could dereference.
142
+ const actors = contacts.followers.filter(f => !f.bsky).map(f => f.actor);
143
+ await publisher.publishFollowers(actors, { acls: which.acls, force: which.force });
144
+ }
145
+ if (which.following) {
146
+ await podFollowing.write(publisher.remote, urls,
147
+ wire.orderedCollection(urls.following, contacts.following.filter(f => f.accepted).map(f => f.actor)),
148
+ { publicRead: which.acls });
149
+ }
150
+ if (which.pending) await publisher.publishPending();
151
+ if (which.blocked) await publisher.publishBlocked();
152
+ if (which.outbox) {
153
+ const outbox = publisher.store.read('outbox.json', []);
154
+ // Reconcile reads every published page, which is the expensive part of a
155
+ // profile save. It is worth paying only when we are about to write pages
156
+ // we did not write: on a repair, or on a machine whose state no longer
157
+ // records what it put up there — which is exactly the restored backup the
158
+ // reconcile exists for. With an intact record our copy IS what the pod
159
+ // has, and re-reading it to confirm that is a page walk for nothing.
160
+ const known = publisher.store.read('published.json', {}).outboxIndex;
161
+ if (which.force || !Array.isArray(known)) await publisher.reconcileOutbox(outbox);
162
+ await publisher.publishOutbox(outbox, { acls: which.acls, force: which.force });
163
+ }
164
+ }
165
+
166
+ // FEP-4ccd: the follows in limbo, as owner-only collections of the Follow
167
+ // activities themselves. Inside the private container so its ACL is
168
+ // inherited — and published only where that ACL provably holds, the same
169
+ // bar private posts clear. A Bluesky-only request has no Follow activity a
170
+ // remote server could ever act on, so it is not listed.
171
+ export async function publishPending(publisher) {
172
+ if (await publisher.privateReady() !== true) return;
173
+ const { urls } = publisher;
174
+ const contacts = publisher.store.getContacts();
175
+ await podPrivate.writePending(publisher.remote, urls, {
176
+ followers: wire.orderedCollection(urls.pendingFollowers,
177
+ publisher.store.getRequests().filter(r => r.activity && !r.bsky).map(r => r.activity)),
178
+ following: wire.orderedCollection(urls.pendingFollowing,
179
+ contacts.following.filter(f => !f.accepted && f.followActivity)
180
+ .map(f => f.followActivity).reverse()),
181
+ });
182
+ }
183
+
184
+ // FEP-c648: the blocked actors, as an owner-only collection. Actors only —
185
+ // domain blocks are ours, and the FEP does not carry them.
186
+ export async function publishBlocked(publisher) {
187
+ if (await publisher.privateReady() !== true) return;
188
+ const { urls } = publisher;
189
+ const actors = [...(publisher.store.getBlocklist().actors || [])].reverse();
190
+ await podPrivate.writeBlocked(publisher.remote, urls, wire.orderedCollection(urls.blocked, actors));
191
+ }
192
+
193
+ // The outbox is the public record of everything this actor has said, boosts
194
+ // included. A Create goes in as its note id, which dereferences; an Announce
195
+ // has only a fragment id, so the activity itself goes in the collection —
196
+ // legal AS2, and what Mastodon serves.
197
+ export async function recordOutbox(publisher, item) {
198
+ const outbox = publisher.store.read('outbox.json', []);
199
+ outbox.unshift(item);
200
+ publisher.store.write('outbox.json', outbox);
201
+ await publisher.publishOutbox(outbox);
202
+ }
203
+
204
+ // Taking something out of the outbox is a DECISION — a post deleted, a boost
205
+ // undone. It leaves a mark for the same reason dropFollower does: the pod's
206
+ // copy is rewritten right after, but a rewrite that fails would otherwise let
207
+ // the next reconcile put the entry back. Bounded, like the follower one.
208
+ export async function unrecordOutbox(publisher, matches) {
209
+ const before = publisher.store.read('outbox.json', []);
210
+ const outbox = before.filter(i => !matches(i));
211
+ const gone = before.filter(i => matches(i))
212
+ .map(i => (typeof i === 'string' ? i : i?.id)).filter(Boolean);
213
+ if (gone.length) {
214
+ const marks = publisher.store.read('outbox-removed.json', []).filter(r => !gone.includes(r.id));
215
+ const at = new Date().toISOString();
216
+ publisher.store.write('outbox-removed.json',
217
+ [...marks, ...gone.map(id => ({ id, at }))].slice(-500));
218
+ }
219
+ publisher.store.write('outbox.json', outbox);
220
+ await publisher.publishOutbox(outbox);
221
+ }
222
+
223
+ // The pinned posts, as the actor's featured collection — the one document a
224
+ // remote server reads when it shows this profile's pins.
225
+ export async function publishFeatured(publisher) {
226
+ const ids = publisher.store.getStatuses().filter(s => s.kind === 'post' && s.pinned).map(s => s.noteId);
227
+ await podFeatured.write(publisher.remote, publisher.urls, wire.orderedCollection(publisher.urls.featured, ids));
228
+ return ids.length;
229
+ }
@@ -0,0 +1,421 @@
1
+ // publisher.mjs — builds/maintains the actor's public face on the remote pod:
2
+ // webfinger, actor doc, collections, notes. The /ap/ tree is disposable:
3
+ // publishProfile() rebuilds it.
4
+ //
5
+ // This file is the actor's public face — the actor document, discovery,
6
+ // ACLs, the inbox posture, Move and retire. The rest is in the modules
7
+ // beside it: collections.mjs (the published collections and the outbox
8
+ // record), restore.mjs (catching up with the pod), notes.mjs (a note going
9
+ // up), questions.mjs (polls) — each a set of functions taking the Publisher
10
+ // as their first argument, reached here through one-line delegations.
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import crypto from 'node:crypto';
16
+ import * as wire from '../wire.mjs';
17
+ import { USER_AGENT } from '../../shared/ua.mjs';
18
+ import { HTTP_TIMEOUT_MS } from '../../shared/safefetch.mjs';
19
+ import * as containers from '../../pod/containers.mjs';
20
+ import * as discovery from '../../pod/discovery.mjs';
21
+ import * as podActor from '../../pod/actor.mjs';
22
+ import * as podInbox from '../../pod/inbox.mjs';
23
+ import * as podFeatured from '../../pod/featured.mjs';
24
+ import * as podPolicy from '../../pod/policy.mjs';
25
+ import * as podNotes from '../../pod/notes.mjs';
26
+ import * as collections from './collections.mjs';
27
+ import { ALL_COLLECTIONS } from './collections.mjs';
28
+ import * as restore from './restore.mjs';
29
+ import * as notes from './notes.mjs';
30
+ import * as questions from './questions.mjs';
31
+
32
+ const AGENT_VERSION = JSON.parse(fs.readFileSync(
33
+ path.join(path.dirname(fileURLToPath(import.meta.url)), '../../../package.json'), 'utf8')).version;
34
+
35
+ export class Publisher {
36
+ constructor({ config, remote, store, deliverer, publicKeyPem, assertionKey = null, log = console.log,
37
+ probeFetch = null, resolveMention = null, clientOrigin = null,
38
+ }) {
39
+ this.config = config;
40
+ this.remote = remote;
41
+ this.store = store;
42
+ this.deliverer = deliverer;
43
+ this.publicKeyPem = publicKeyPem;
44
+ this.assertionKey = assertionKey; // Ed25519 public half, multibase; null = no proofs
45
+ // Where this identity's client surface answers, when that is an address a
46
+ // stranger can reach. Null on a laptop, where the surface is on loopback
47
+ // and advertising it to the world would name somewhere nobody can go.
48
+ this.clientOrigin = clientOrigin;
49
+ // A fronted identity (config.gateway.frontActor) advertises its ids on a
50
+ // shared domain; the map tells RemotePod where each writes on the pod.
51
+ const publicBase = config.gateway?.frontActor
52
+ ? config.gateway.frontActor.replace(/ap\/actor\/?$/, '') : null;
53
+ this.urls = wire.apUrls(config.remotePod, config.root, { publicBase });
54
+ if (this.urls.toPod && this.remote?.setUrlMap) this.remote.setUrlMap(this.urls.toPod);
55
+ // Credential-free by design — it asks what a stranger sees — but routed
56
+ // through the pod's own cooldown and accounting, because it is still a
57
+ // socket opened to that pod. Tests inject their own.
58
+ this.probeFetch = probeFetch || ((u, i) => this.remote.probe(u, i));
59
+ this.resolveMention = resolveMention;
60
+ // Per-poll rewrite windows, keyed by question id. See POLL_REWRITE_MS.
61
+ this.pollTimers = new Map();
62
+ this.log = log;
63
+ }
64
+
65
+ // Idempotent: (re)write webfinger + actor + collections + container ACLs.
66
+ //
67
+ // ~34 pod requests, so it does not run when it would rewrite the same bytes.
68
+ // Every document below is derived from the actor doc, the handle, the host,
69
+ // whether the actor is quiesced (which decides the inbox ACL) and the agent
70
+ // version (which rides in nodeinfo) — so if none of those moved, there is
71
+ // nothing to say. Phanpy's editor submits the whole form on every save, so
72
+ // "saved without changing anything" is the common case, not a rare one.
73
+ //
74
+ // `force` is for the callers that publish precisely BECAUSE the pod does not
75
+ // have what the digest says it has: the repair path, and the explicit
76
+ // republish button. Without it, an actor lost from the pod would match the
77
+ // digest, be skipped, and leave the agent reporting success while nobody can
78
+ // resolve it.
79
+ async publishProfile({ force = false } = {}) {
80
+ const { urls } = this;
81
+ const host = new URL(urls.base).host;
82
+
83
+ // The owner-only collections (FEP-4ccd, FEP-c648) are advertised only
84
+ // where "owner-only" is real — the pod must provably enforce the private
85
+ // container's ACL, the same bar private posts clear.
86
+ const priv = await this.privateReady() === true;
87
+ const moderators = (this.config.moderators || []).length ? urls.moderators : null;
88
+ // An advertised inbox gateway (config.gateway.mode past 'off') becomes the
89
+ // actor's inbox; deliveries reach the pod inbox through it. Absent → today.
90
+ const gw = this.config.gateway;
91
+ const gwActive = gw && gw.url && gw.mode && gw.mode !== 'off';
92
+ const actorDoc = wire.actorDoc({
93
+ urls, handle: wire.publicHandle(this.config), name: this.config.name, publicKeyPem: this.publicKeyPem,
94
+ movedTo: this.config.movedTo || null, kind: this.config.kind,
95
+ approveJoins: wire.followsNeedApproval(this.config),
96
+ assertionKey: this.assertionKey,
97
+ summary: this.config.summary || null, icon: this.config.icon || null,
98
+ image: this.config.image || null, fields: this.config.fields || [],
99
+ webId: this.remote.webId || null,
100
+ aliases: this.config.aliases || [],
101
+ moderators,
102
+ pendingFollowers: priv ? urls.pendingFollowers : null,
103
+ pendingFollowing: priv ? urls.pendingFollowing : null,
104
+ blocked: priv ? urls.blocked : null,
105
+ inbox: gwActive ? gw.url : null,
106
+ // The agent's own outbox endpoint, where it is reachable: a client
107
+ // following the actor must arrive somewhere that will take a write.
108
+ outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : null,
109
+ // How a client-to-server client finds the way in with nothing configured
110
+ // by hand. Advertised only where the surface is publicly reachable.
111
+ oauthAuthorize: this.clientOrigin ? `${this.clientOrigin}oauth/authorize` : null,
112
+ oauthToken: this.clientOrigin ? `${this.clientOrigin}oauth/token` : null,
113
+ });
114
+ const surface = crypto.createHash('sha256').update(JSON.stringify({
115
+ actor: actorDoc, handle: this.config.handle, host,
116
+ quiesced: !!this.config.quiescedAt, version: AGENT_VERSION,
117
+ moderators: this.config.moderators || [],
118
+ })).digest('hex').slice(0, 32);
119
+ if (!force && this.store.read('published.json', {}).surfaceDigest === surface) {
120
+ this.log('profile unchanged — nothing republished');
121
+ return { unreachable: [], updated: 0, skipped: true };
122
+ }
123
+
124
+ await discovery.writeWebfinger(this.remote, urls,
125
+ wire.jrd({ handle: this.config.handle, host, actor: urls.actor }));
126
+ await discovery.writeHostMeta(this.remote, urls, wire.hostMeta(urls.base));
127
+
128
+ const nodeinfoDocUrl = urls.home + 'ap/nodeinfo-2.0';
129
+ const localPosts = this.store.getStatuses().filter(s => s.kind === 'post').length;
130
+ await discovery.writeNodeinfo(this.remote, urls, {
131
+ pointer: wire.nodeinfoPointer(nodeinfoDocUrl),
132
+ doc: wire.nodeinfoDoc({ version: AGENT_VERSION, localPosts }),
133
+ });
134
+
135
+ const actor = actorDoc; // built above, for the digest
136
+ await podActor.write(this.remote, urls, actor);
137
+
138
+ // FEP-1b12: the moderator roster, public — it is what a recipient
139
+ // validates a group's announced moderation against.
140
+ if (moderators) {
141
+ await podFeatured.writeModerators(this.remote, urls,
142
+ wire.orderedCollection(urls.moderators, this.config.moderators));
143
+ }
144
+ // The gateway policy doc: the PUBLIC data a keyless gateway reads to decide
145
+ // what concerns this identity. Written only while a gateway is advertised.
146
+ if (gwActive) await this.publishGatewayPolicy();
147
+
148
+ // The human half: a page a browser can open and follow from. The actor
149
+ // document is for servers; this is the address you hand to a person.
150
+ await podActor.writeProfilePage(this.remote, urls, wire.profilePageHtml({
151
+ name: this.config.name || this.config.handle,
152
+ address: wire.webfingerHost(urls.base) ? `@${this.config.handle}@${host}` : urls.actor,
153
+ summary: this.config.summary ? wire.contentHtml(this.config.summary) : null,
154
+ icon: this.config.icon || null,
155
+ kind: this.config.kind,
156
+ }));
157
+
158
+ // WebID → actor: the profile card lists the actor as a foaf:account.
159
+ // Best-effort — a profile that cannot be read or edited does not stop the
160
+ // publish, it is logged and the rest of the surface still goes up.
161
+ try {
162
+ const wrote = await podActor.linkInWebIdProfile(this.remote, {
163
+ actorUrl: urls.actor,
164
+ accountName: `@${this.config.handle}@${host}`,
165
+ kind: this.config.kind,
166
+ });
167
+ if (wrote) this.log('WebID profile now lists the actor as a foaf:account');
168
+ } catch (e) {
169
+ this.log(`WebID profile not updated with the actor link: ${e.message}`);
170
+ }
171
+
172
+ // inbox: public may only Append; owner (the agent) reads + drains. A
173
+ // quiesced actor keeps its name resolving but takes no more mail, so a
174
+ // republish must not re-open the door.
175
+ await podInbox.writeKeep(this.remote, urls);
176
+ await podInbox.setPosture(this.remote, urls, this.config.quiescedAt ? 'closed' : 'open');
177
+ if (this.config.quiescedAt) this.log('inbox left closed — this actor is quiesced');
178
+
179
+ // notes live under a public-Read container (acl:default covers new notes).
180
+ await podNotes.provisionContainer(this.remote, urls);
181
+
182
+ await this.publishCollections({ ...ALL_COLLECTIONS, force });
183
+ const updated = await this.announceProfileChange(actor, { force });
184
+ await this.ensurePrivateAcls();
185
+ const unreachable = await this.verifyPublicSurface();
186
+ // Last, and merged: announceProfileChange writes this document too.
187
+ //
188
+ // Only when the surface came back readable. Recording it regardless meant a
189
+ // publish that half-landed still matched the digest, so the NEXT save — the
190
+ // one the operator makes because the first did not work — was skipped as a
191
+ // no-op and reported success. The digest is a record of what is up there,
192
+ // and an unreachable document is not up there.
193
+ if (!unreachable.length) {
194
+ this.store.write('published.json',
195
+ { ...this.store.read('published.json', {}), surfaceDigest: surface });
196
+ }
197
+ // Named as the fediverse sees it: a fronted actor's name and host are the
198
+ // front's, not the pod's.
199
+ const pubName = wire.publicHandle(this.config);
200
+ const pubHost = this.config.gateway?.frontActor ? new URL(this.config.gateway.frontActor).host : host;
201
+ this.log(this.config.gateway?.frontActor || wire.webfingerHost(urls.base)
202
+ ? `profile published: @${pubName}@${pubHost} → ${urls.actor}`
203
+ : `profile published → ${urls.actor} — NOT discoverable as @${pubName}@${pubHost}: `
204
+ + 'this pod is a path on a shared host, and WebFinger is only answered at a host root');
205
+ return { unreachable, updated };
206
+ }
207
+
208
+ // Fires when the document differs from the one last published — including
209
+ // the first time, when there is nothing to differ from.
210
+ //
211
+ // Every call to publishProfile is already a DELIBERATE republish: setup, a
212
+ // rename, an edit through the client or /config, `describe`, a key rotation,
213
+ // or an actor document found missing from the pod. Starting the agent does not
214
+ // call it. So the digest is not there to survive restarts — it is there for
215
+ // the republish that changes nothing, which /config does whenever you save a
216
+ // field the actor document does not carry.
217
+ //
218
+ // A silent first publish was considered and is WRONG here: it would spend a
219
+ // real edit doing nothing but recording a digest, and that edit is exactly the
220
+ // one whose invisibility this fixes.
221
+ async announceProfileChange(actor, { force = false } = {}) {
222
+ const digest = crypto.createHash('sha256').update(JSON.stringify(actor)).digest('hex').slice(0, 32);
223
+ const seen = this.store.read('published.json', {});
224
+ // A forced republish is the operator saying TELL THE WORLD — the digest
225
+ // gate is for silent no-op saves, not for that.
226
+ if (seen.actorDigest === digest && !force) return 0;
227
+ this.store.write('published.json', { ...seen, actorDigest: digest, at: new Date().toISOString() });
228
+ const inboxes = [...new Set(this.store.getContacts().followers
229
+ .map(f => f.sharedInbox || f.inbox).filter(Boolean))];
230
+ if (!inboxes.length) return 0;
231
+ await this.deliverer.deliverToAll(inboxes,
232
+ wire.updateActorActivity({ urls: this.urls, actor, serial: Date.now() }));
233
+ this.log(`profile changed — Update delivered to ${inboxes.length} inbox(es)`);
234
+ return inboxes.length;
235
+ }
236
+
237
+ // The mirror of ensurePrivateAcls, and the check this project lacked: these
238
+ // documents MUST be readable by strangers or no server can see the actor.
239
+ // A publish that dies half-way, or an ACL write that is accepted without
240
+ // taking effect, is otherwise indistinguishable from success — the agent
241
+ // reports itself configured and federating while nobody can find it.
242
+ async verifyPublicSurface() {
243
+ const { urls } = this;
244
+ const targets = [
245
+ ['webfinger', urls.webfinger],
246
+ ['host-meta', urls.base + '.well-known/host-meta'],
247
+ ['actor', urls.actor],
248
+ ['notes', urls.notes],
249
+ ['followers', urls.followers],
250
+ ['following', urls.following],
251
+ ['outbox', urls.outbox],
252
+ ];
253
+ const unreachable = [];
254
+ for (const [name, url] of targets) {
255
+ if (!await this.publiclyReadable(url)) unreachable.push(name);
256
+ }
257
+ if (unreachable.length) {
258
+ this.log(`FEDERATION: ${unreachable.join(', ')} not readable without credentials — `
259
+ + 'other servers cannot resolve or fetch this actor');
260
+ }
261
+ return unreachable;
262
+ }
263
+
264
+ // An ACL write that silently failed — or was changed afterwards by anything
265
+ // else touching the pod — leaves the private trees, signing keys included,
266
+ // world-readable. bootstrap writes them once at setup and never returns, so
267
+ // this runs on every connect: probe UNauthenticated, rewrite whatever
268
+ // answers, and say so loudly if the rewrite does not take.
269
+ async ensurePrivateAcls() {
270
+ const findings = await containers.repairPrivateAcls(this.remote,
271
+ [this.urls.home, this.urls.state],
272
+ { isPublic: (u) => this.publiclyReadable(u) });
273
+ // What a finding MEANS is ours to say; the library just reports.
274
+ for (const f of findings) {
275
+ this.log(`${f.url} was readable without credentials — rewriting its ACL`);
276
+ if (f.error) { this.log(`SECURITY: ${f.url} is public and its ACL could not be rewritten: ${f.error}`); continue; }
277
+ if (f.stillPublic) this.log(`SECURITY: ${f.url} is STILL readable without credentials — check the pod's ACLs`);
278
+ }
279
+ }
280
+
281
+ // Deliberately credential-free: this asks what a stranger would see.
282
+ // accept: */* matters — asking for turtle makes the server answer 501 on the
283
+ // JSON documents (webfinger, actor), which reads as "unreachable" when the
284
+ // world can in fact see them perfectly well.
285
+ publiclyReadable(url) {
286
+ return containers.probePublicReadability(this.probeFetch, url,
287
+ { headers: { 'user-agent': USER_AGENT }, timeoutMs: HTTP_TIMEOUT_MS });
288
+ }
289
+
290
+ // Retire this identity for good: tell everyone who follows us to drop the
291
+ // account, then leave a Tombstone where the actor was. The inbox stays
292
+ // publicly Append-able on purpose — closing it would make deliveries 401,
293
+ // which Mastodon treats as failure and retries, the opposite of the point.
294
+ // A Delete stops well-behaved servers; anything that keeps delivering gets a
295
+ // cheap 201 into a container we no longer read.
296
+ async retireActor() {
297
+ const { urls } = this;
298
+ const contacts = this.store.getContacts();
299
+ const inboxes = [...new Set(contacts.followers.map(f => f.sharedInbox || f.inbox).filter(Boolean))];
300
+ const deletedAt = new Date().toISOString();
301
+ await this.deliverer.deliverToAll(inboxes, wire.deleteActorActivity(urls, Date.parse(deletedAt)));
302
+ await podActor.writeTombstone(this.remote, urls, wire.tombstoneDoc(urls, deletedAt, this.config.kind));
303
+ this.store.setConfig({ ...this.store.getConfig(), retiredAt: deletedAt });
304
+ await this.store.flush();
305
+ this.log(`retired: Delete sent to ${inboxes.length} inbox(es), actor replaced with a Tombstone`);
306
+ return { inboxes: inboxes.length, deletedAt };
307
+ }
308
+
309
+ // Stop accepting mail without giving up the name: deliveries get an immediate
310
+ // 401 rather than a 201 into storage nobody will ever drain. WebFinger,
311
+ // host-meta and the actor stay published, so the handle still resolves.
312
+ async closeInbox() {
313
+ await podInbox.setPosture(this.remote, this.urls, 'closed');
314
+ const at = new Date().toISOString();
315
+ this.store.setConfig({ ...this.store.getConfig(), quiescedAt: at });
316
+ await this.store.flush();
317
+ this.log(`inbox closed — @${this.config.handle} still resolves but accepts nothing`);
318
+ return at;
319
+ }
320
+
321
+ // Undo closeInbox: mail flows again and the actor is no longer quiesced.
322
+ async openInbox() {
323
+ await podInbox.setPosture(this.remote, this.urls, 'open');
324
+ const { quiescedAt, ...rest } = this.store.getConfig() || {};
325
+ this.store.setConfig(rest);
326
+ this.config.quiescedAt = undefined;
327
+ await this.store.flush();
328
+ this.log(`inbox re-opened — @${this.config.handle} is taking mail again`);
329
+ }
330
+
331
+ // Lock the inbox to a gateway: the public loses Append, so nothing reaches
332
+ // the pod except through the gateway's verify-at-the-door. Reversible with
333
+ // openInbox (public-Append) — the one-call rollback.
334
+ async lockInboxToGateway(gatewayWebId) {
335
+ await podInbox.setPosture(this.remote, this.urls, { gatewayWebId });
336
+ this.log(`inbox locked to gateway ${gatewayWebId} — public delivery is refused`);
337
+ }
338
+
339
+ // Tell the fediverse the account lives somewhere else now. Well-behaved
340
+ // servers migrate their followers to the target and stop delivering here.
341
+ async publishMove(target) {
342
+ const { urls } = this;
343
+ const contacts = this.store.getContacts();
344
+ const inboxes = [...new Set(contacts.followers.map(f => f.sharedInbox || f.inbox).filter(Boolean))];
345
+ const at = new Date().toISOString();
346
+ await this.deliverer.deliverToAll(inboxes, wire.moveActivity(urls, target, Date.parse(at)));
347
+ this.config.movedTo = target; // so the republish below carries it
348
+ this.store.setConfig({ ...this.store.getConfig(), movedTo: target, movedAt: at });
349
+ await podActor.writeMoved(this.remote, urls, wire.actorDoc({
350
+ urls, handle: wire.publicHandle(this.config), name: this.config.name,
351
+ publicKeyPem: this.publicKeyPem, movedTo: target, kind: this.config.kind,
352
+ approveJoins: wire.followsNeedApproval(this.config),
353
+ assertionKey: this.assertionKey,
354
+ summary: this.config.summary || null, icon: this.config.icon || null,
355
+ image: this.config.image || null, fields: this.config.fields || [],
356
+ webId: this.remote.webId || null,
357
+ aliases: this.config.aliases || [],
358
+ }));
359
+ await this.store.flush();
360
+ this.log(`moved to ${target}: Move sent to ${inboxes.length} inbox(es), actor now advertises movedTo`);
361
+ return { inboxes: inboxes.length, target, movedAt: at };
362
+ }
363
+
364
+ // The gateway policy: a small PUBLIC document a keyless inbox gateway reads
365
+ // to decide, at the edge, what to forward and what to drop. It carries only
366
+ // public facts — the actor/followers URLs, the REAL pod inbox to forward to,
367
+ // the accepted-following list (already public) and a mirror of the blocklist.
368
+ // Publishing the blocklist here makes it public; that is a deliberate part of
369
+ // running a gateway, surfaced to the operator in the admin UI.
370
+ async publishGatewayPolicy() {
371
+ const { urls } = this;
372
+ const contacts = this.store.getContacts();
373
+ const bl = this.store.getBlocklist();
374
+ await podPolicy.write(this.remote, urls, {
375
+ v: 1,
376
+ actorUrl: urls.actor,
377
+ followersUrl: urls.followers,
378
+ followingUrl: urls.following,
379
+ inboxUrl: urls.inbox, // the pod inbox the gateway forwards to
380
+ notesPrefix: urls.notes,
381
+ kind: this.config.kind || 'person',
382
+ following: contacts.following.filter(f => f.accepted && !f.bsky).map(f => f.actor),
383
+ blocklist: { domains: bl.domains || [], actors: bl.actors || [] },
384
+ });
385
+ }
386
+
387
+ // collections.mjs
388
+ publishOutbox(...a) { return collections.publishOutbox(this, ...a); }
389
+ readPublishedOutbox(...a) { return collections.readPublishedOutbox(this, ...a); }
390
+ publishFollowers(...a) { return collections.publishFollowers(this, ...a); }
391
+ readPublishedFollowers(...a) { return collections.readPublishedFollowers(this, ...a); }
392
+ publishCollections(...a) { return collections.publishCollections(this, ...a); }
393
+ publishPending(...a) { return collections.publishPending(this, ...a); }
394
+ publishBlocked(...a) { return collections.publishBlocked(this, ...a); }
395
+ recordOutbox(...a) { return collections.recordOutbox(this, ...a); }
396
+ unrecordOutbox(...a) { return collections.unrecordOutbox(this, ...a); }
397
+ publishFeatured(...a) { return collections.publishFeatured(this, ...a); }
398
+
399
+ // restore.mjs
400
+ reconcileFollowers(...a) { return restore.reconcileFollowers(this, ...a); }
401
+ reconcileOutbox(...a) { return restore.reconcileOutbox(this, ...a); }
402
+ rebuildStatuses(...a) { return restore.rebuildStatuses(this, ...a); }
403
+
404
+ // notes.mjs
405
+ ensureMediaContainer(...a) { return notes.ensureMediaContainer(this, ...a); }
406
+ _containerExists(...a) { return notes.containerExists(this, ...a); }
407
+ ensurePrivateContainer(...a) { return notes.ensurePrivateContainer(this, ...a); }
408
+ privateReady(...a) { return notes.privateReady(this, ...a); }
409
+ _mentionsFor(...a) { return notes.mentionsFor(this, ...a); }
410
+ publishNote(...a) { return notes.publishNote(this, ...a); }
411
+ updateNote(...a) { return notes.updateNote(this, ...a); }
412
+
413
+ // questions.mjs
414
+ publishQuestion(...a) { return questions.publishQuestion(this, ...a); }
415
+ _pollActivity(...a) { return questions.pollActivity(this, ...a); }
416
+ recordVote(...a) { return questions.recordVote(this, ...a); }
417
+ _pollDirty(...a) { return questions.pollDirty(this, ...a); }
418
+ republishPoll(...a) { return questions.republishPoll(this, ...a); }
419
+ closeDuePolls(...a) { return questions.closeDuePolls(this, ...a); }
420
+ stopPolls(...a) { return questions.stopPolls(this, ...a); }
421
+ }