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,188 @@
1
+ // notes.mjs — a note of the actor's own going up: the containers it needs
2
+ // (media, and the owner-only one private posts live in), who it mentions,
3
+ // publishing it, and editing it afterwards.
4
+
5
+ import crypto from 'node:crypto';
6
+ import * as wire from '../wire.mjs';
7
+ import * as containers from '../../pod/containers.mjs';
8
+ import * as podNotes from '../../pod/notes.mjs';
9
+
10
+ // Media container on the remote pod — public-Read like notes, created lazily
11
+ // at first upload (idempotent; the flag only saves round-trips).
12
+ // Ask before writing. `_mediaReady` is per PROCESS, and a service worker is
13
+ // restarted whenever the browser feels like it — so on the browser build this
14
+ // re-created a container that has existed since sign-up, and rewrote its ACL,
15
+ // on every restart. A HEAD is one request and usually the only one.
16
+ export async function ensureMediaContainer(publisher) {
17
+ if (publisher._mediaReady) return;
18
+ if (await containers.exists(publisher.remote, publisher.urls.media)) { publisher._mediaReady = true; return; }
19
+ await containers.provisionPublic(publisher.remote, publisher.urls.media);
20
+ publisher._mediaReady = true;
21
+ }
22
+
23
+ // Whether the canary this class writes is already there. Only a definite
24
+ // "yes" counts: anything else falls through to the write, which is
25
+ // idempotent anyway, so a failed probe costs a request and never correctness.
26
+ export function containerExists(publisher, base) { return containers.exists(publisher.remote, base); }
27
+
28
+ // The owner-only container that followers-only and direct posts live in.
29
+ // Its ACL is set once; every note under it inherits.
30
+ export async function ensurePrivateContainer(publisher) {
31
+ if (publisher._privateContainer) return;
32
+ // Same reasoning as ensureMediaContainer: probe before provisioning, so a
33
+ // worker restart is not a re-provision. The ACL is NOT skipped on the
34
+ // strength of the container existing alone — ensurePrivateAcls re-checks
35
+ // that separately on every connect, which is the control that matters here.
36
+ if (await containers.exists(publisher.remote, publisher.urls.privateNotes)) { publisher._privateContainer = true; return; }
37
+ await containers.provisionOwnerOnly(publisher.remote, publisher.urls.privateNotes);
38
+ publisher._privateContainer = true;
39
+ }
40
+
41
+ // Whether the pod actually enforces that ACL: a bare, unauthenticated read
42
+ // of the private container's canary must be refused. Returns true, or the
43
+ // reason private posts stay off. A definite answer is cached for the run; a
44
+ // network failure is not, so a pod that was briefly unreachable is asked
45
+ // again rather than refused forever.
46
+ export async function privateReady(publisher) {
47
+ if (publisher._privateVerdict !== undefined) return publisher._privateVerdict;
48
+ try {
49
+ await publisher.ensurePrivateContainer();
50
+ // The probe is credential-free and bypasses RemotePod's url map, so a
51
+ // fronted identity must map the advertised private container back to the
52
+ // pod itself — the ACL we are testing is the pod's.
53
+ const pn = publisher.urls.toPod ? publisher.urls.toPod(publisher.urls.privateNotes) : publisher.urls.privateNotes;
54
+ const verdict = await containers.probePrivateEnforcement(publisher.probeFetch || fetch, pn + '.keep');
55
+ publisher._privateVerdict = verdict === true ? true : `${verdict} — private posts stay off it`;
56
+ return publisher._privateVerdict;
57
+ } catch (e) {
58
+ return `could not verify that the pod protects private posts (${e.message})`;
59
+ }
60
+ }
61
+
62
+ // Compose → wire note on remote pod + RDF truth locally + deliver Create.
63
+ // Who this text mentions, resolved. A mention nobody can resolve stays plain
64
+ // text rather than failing the post. The text itself decides: trim a handle
65
+ // out and that person is not notified, which is what every fediverse client
66
+ // leads people to expect. A Group named in the parent is the one thing
67
+ // carried forward regardless — drop it and the group stops carrying the
68
+ // thread.
69
+ export async function mentionsFor(publisher, content, inReplyTo) {
70
+ const inText = new Set(wire.mentionsIn(content));
71
+ const carried = inReplyTo
72
+ ? (publisher.store.getStatuses().find(s => s.noteId === inReplyTo)?.mentions || [])
73
+ .map(m => String(m.name || '').replace(/^@/, '')).filter(Boolean)
74
+ : [];
75
+ const mentions = [];
76
+ for (const handle of [...new Set([...inText, ...carried])]) {
77
+ if (!publisher.resolveMention) break;
78
+ const doc = await publisher.resolveMention(handle).catch(() => null);
79
+ if (!doc?.id) { publisher.log(`mention @${handle} did not resolve — left as text`); continue; }
80
+ if (!inText.has(handle) && doc.type !== 'Group') continue; // author trimmed them out
81
+ mentions.push({ handle, actor: doc.id, page: doc.url || null, inbox: doc.endpoints?.sharedInbox || doc.inbox });
82
+ }
83
+ return mentions;
84
+ }
85
+
86
+ export async function publishNote(publisher, content, { inReplyTo, attachments, visibility = 'public', spoilerText = null } = {}) {
87
+ const { urls } = publisher;
88
+ const priv = visibility === 'private' || visibility === 'direct';
89
+ if (priv) {
90
+ const ready = await publisher.privateReady();
91
+ if (ready !== true) throw new Error(ready);
92
+ }
93
+ const published = new Date().toISOString();
94
+ const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
95
+ const mentions = await publisher._mentionsFor(content, inReplyTo);
96
+ const note = wire.noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions,
97
+ visibility, summary: spoilerText, container: priv ? urls.privateNotes : urls.notes });
98
+
99
+ await podNotes.write(publisher.remote, note.id, note);
100
+ // Empty, but present: a dangling `replies` that 404s is worse than none.
101
+ await podNotes.writeEmptyReplies(publisher.remote, wire.repliesId(note.id),
102
+ wire.collection(wire.repliesId(note.id), []));
103
+ // The outbox is the PUBLIC index; a private or direct post is not in it.
104
+ if (!priv) await publisher.recordOutbox(note.id);
105
+
106
+ publisher.store.addStatus({
107
+ noteId: note.id, actor: urls.actor, content: note.content, published, inReplyTo,
108
+ kind: 'post', slug, text: content, visibility,
109
+ ...(spoilerText ? { spoiler: spoilerText } : {}),
110
+ ...(attachments?.length ? { attachments } : {}),
111
+ ...(note.tag?.length ? { mentions: note.tag.map(t => ({ href: t.href, name: t.name })) } : {}),
112
+ });
113
+
114
+ const create = wire.createActivity(note, urls);
115
+ // Published as its own document: a group that carries this post wraps the
116
+ // whole activity, and the receiving server resolves it by fetching this id.
117
+ // It inherits the notes container's public-Read acl:default.
118
+ await podNotes.writeCreate(publisher.remote, create.id, create);
119
+ const contacts = publisher.store.getContacts();
120
+ // A direct post goes to the people it names and to nobody else.
121
+ const inboxes = [...new Set([
122
+ ...(visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
123
+ ...mentions.map(m => m.inbox),
124
+ ].filter(Boolean))];
125
+ await publisher.deliverer.deliverToAll(inboxes, create);
126
+ publisher.log(`note published: ${note.id} → ${inboxes.length} inbox(es)`);
127
+
128
+ // Bluesky mirror: PUBLIC posts only — unlisted, followers-only and direct
129
+ // are never carried off the fediverse. A mirror failure never fails the
130
+ // post; it is logged and recorded on the status for the admin page.
131
+ if (visibility === 'public' && publisher.atproto?.connected()
132
+ && publisher.store.getConfig()?.atproto?.crossPost) {
133
+ try {
134
+ const mirror = await publisher.atproto.crossPost(
135
+ { text: content, published, attachments }, { noteUrl: note.id });
136
+ publisher.store.updateStatus(note.id, { atproto: mirror });
137
+ publisher.log(`cross-posted to bluesky: ${mirror.uri}${mirror.truncated ? ' (truncated, links back)' : ''}`);
138
+ } catch (e) {
139
+ publisher.store.updateStatus(note.id, { atproto: { error: e.message } });
140
+ publisher.log(`bluesky cross-post failed: ${e.message}`);
141
+ }
142
+ }
143
+ return note;
144
+ }
145
+
146
+ // An edit keeps the note's id, slug and published time; `updated` is the
147
+ // edit's own stamp. The pod documents are overwritten in place — the Create
148
+ // too, so a group's Announce resolves to the edited text — and an Update
149
+ // goes everywhere the Create went.
150
+ export async function updateNote(publisher, s, { content, spoilerText = null, attachments = null } = {}) {
151
+ const { urls } = publisher;
152
+ const updated = new Date().toISOString();
153
+ const inText = new Set(wire.mentionsIn(content));
154
+ const mentions = [];
155
+ for (const handle of inText) {
156
+ if (!publisher.resolveMention) break;
157
+ const doc = await publisher.resolveMention(handle).catch(() => null);
158
+ if (!doc?.id) { publisher.log(`mention @${handle} did not resolve — left as text`); continue; }
159
+ mentions.push({ handle, actor: doc.id, page: doc.url || null, inbox: doc.endpoints?.sharedInbox || doc.inbox });
160
+ }
161
+ const atts = attachments ?? s.attachments ?? [];
162
+ // The note stays in the container its visibility put it in; a recovered
163
+ // post may carry no slug, but the note id already contains it.
164
+ const container = String(s.noteId).startsWith(urls.privateNotes) ? urls.privateNotes : urls.notes;
165
+ const slug = s.slug || String(s.noteId).slice(container.length);
166
+ const note = wire.noteDoc({
167
+ urls, slug, content, published: s.published, inReplyTo: s.inReplyTo,
168
+ attachments: atts, mentions, visibility: s.visibility || 'public',
169
+ summary: spoilerText, updated, container,
170
+ });
171
+ await podNotes.write(publisher.remote, note.id, note);
172
+ await podNotes.writeCreate(publisher.remote, wire.createActivityId(note.id), wire.createActivity(note, urls));
173
+ const patched = publisher.store.updateStatus(s.noteId, {
174
+ content: note.content, text: content, editedAt: updated,
175
+ spoiler: spoilerText || undefined,
176
+ attachments: atts.length ? atts : undefined,
177
+ mentions: note.tag?.length ? note.tag.map(t => ({ href: t.href, name: t.name })) : undefined,
178
+ });
179
+ const update = wire.updateActivity(note, urls);
180
+ const contacts = publisher.store.getContacts();
181
+ const inboxes = [...new Set([
182
+ ...(s.visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
183
+ ...mentions.map(m => m.inbox),
184
+ ].filter(Boolean))];
185
+ await publisher.deliverer.deliverToAll(inboxes, update);
186
+ publisher.log(`note edited: ${note.id} → ${inboxes.length} inbox(es)`);
187
+ return patched;
188
+ }
@@ -0,0 +1,233 @@
1
+ // questions.mjs — polls.
2
+ //
3
+ // A poll is published as a Question and answered by ordinary replies naming
4
+ // an option, so the count is ours to keep and ours to republish. The roster
5
+ // of who chose what lives in agent state (polls.VOTES_DOC); the tallies on
6
+ // the status row and in the pod document are always derived from it.
7
+
8
+ import crypto from 'node:crypto';
9
+ import * as wire from '../wire.mjs';
10
+ import * as polls from '../polls.mjs';
11
+ import * as podNotes from '../../pod/notes.mjs';
12
+
13
+ // How long a poll gathers votes before its Question is rewritten. Every vote
14
+ // changes a number other servers re-read, and rewriting per vote would make a
15
+ // busy poll a steady write stream against the pod. A burst costs one rewrite
16
+ // and one Update instead.
17
+ const POLL_REWRITE_MS = 10_000;
18
+
19
+ /**
20
+ * Publish a poll. `options` is a list of choice titles, `multiple` lets a
21
+ * voter pick more than one, and `expiresAt` is when voting stops.
22
+ */
23
+ export async function publishQuestion(publisher, content, { options = [], multiple = false, expiresAt = null,
24
+ inReplyTo = undefined, visibility = 'public', spoilerText = null } = {}) {
25
+ const { urls } = publisher;
26
+ const priv = visibility === 'private' || visibility === 'direct';
27
+ if (priv) {
28
+ const ready = await publisher.privateReady();
29
+ if (ready !== true) throw new Error(ready);
30
+ }
31
+ const titles = [].concat(options).map(o => String(o ?? '').trim()).filter(Boolean);
32
+ if (titles.length < 2) throw new Error('a poll needs at least two options');
33
+ // Options are matched BY NAME when a vote arrives — that is the whole of
34
+ // the convention — so two options reading the same are one option that
35
+ // cannot be told apart.
36
+ if (new Set(titles).size !== titles.length) throw new Error('a poll’s options must differ from one another');
37
+ // Bounded here as well as at the client API, because the outbox is a
38
+ // second way in and an unbounded poll is a document a stranger sizes.
39
+ if (titles.length > polls.MAX_OPTIONS) throw new Error(`a poll takes at most ${polls.MAX_OPTIONS} options`);
40
+ if (titles.some(t => t.length > polls.MAX_OPTION_CHARS)) {
41
+ throw new Error(`a poll option is at most ${polls.MAX_OPTION_CHARS} characters`);
42
+ }
43
+ if (expiresAt) {
44
+ const ends = Date.parse(expiresAt);
45
+ if (!Number.isFinite(ends)) throw new Error('the closing time is not a date');
46
+ const seconds = (ends - Date.now()) / 1000;
47
+ if (seconds < polls.MIN_SECONDS || seconds > polls.MAX_SECONDS) {
48
+ throw new Error(`a poll runs between ${polls.MIN_SECONDS} and ${polls.MAX_SECONDS} seconds`);
49
+ }
50
+ }
51
+
52
+ const published = new Date().toISOString();
53
+ const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
54
+ const mentions = await publisher._mentionsFor(content, inReplyTo);
55
+ const poll = {
56
+ multiple: !!multiple,
57
+ expiresAt: expiresAt || null,
58
+ closed: null,
59
+ options: titles.map(title => ({ title, votes: 0 })),
60
+ votersCount: 0,
61
+ // Resolved once, here: a tally rewrite must not cost a webfinger lookup
62
+ // per vote for people the poll named.
63
+ mentionInboxes: [...new Set(mentions.map(m => m.inbox).filter(Boolean))],
64
+ };
65
+ const question = wire.questionDoc({
66
+ urls, slug, content, published, inReplyTo, attachments: [], mentions,
67
+ visibility, summary: spoilerText,
68
+ container: priv ? urls.privateNotes : urls.notes,
69
+ options: poll.options, multiple: poll.multiple, endTime: poll.expiresAt, votersCount: 0,
70
+ });
71
+
72
+ await podNotes.write(publisher.remote, question.id, question);
73
+ // Empty, but present: a dangling `replies` that 404s is worse than none.
74
+ await podNotes.writeEmptyReplies(publisher.remote, wire.repliesId(question.id),
75
+ wire.collection(wire.repliesId(question.id), []));
76
+ if (!priv) await publisher.recordOutbox(question.id);
77
+ publisher.store.addStatus({
78
+ noteId: question.id, actor: urls.actor, content: question.content, published,
79
+ kind: 'post', slug, text: content, visibility, poll, inReplyTo,
80
+ ...(spoilerText ? { spoiler: spoilerText } : {}),
81
+ ...(question.tag?.length ? { mentions: question.tag.map(t => ({ href: t.href, name: t.name })) } : {}),
82
+ });
83
+
84
+ const create = publisher._pollActivity('Create', question, wire.createActivityId(question.id));
85
+ await podNotes.writeCreate(publisher.remote, create.id, create);
86
+ const contacts = publisher.store.getContacts();
87
+ const inboxes = [...new Set([
88
+ ...(visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
89
+ ...poll.mentionInboxes,
90
+ ].filter(Boolean))];
91
+ await publisher.deliverer.deliverToAll(inboxes, create);
92
+ publisher.log(`poll published: ${question.id} (${titles.length} options) → ${inboxes.length} inbox(es)`);
93
+ return question;
94
+ }
95
+
96
+ // The Create or Update carrying a Question. The context is hoisted onto the
97
+ // activity and the embedded object keeps none: a nested @context is legal
98
+ // JSON-LD but not every server reads one, and votersCount is declared there.
99
+ export function pollActivity(publisher, type, question, id) {
100
+ const { '@context': ctx, ...object } = question;
101
+ return {
102
+ '@context': ctx, id, type,
103
+ actor: publisher.urls.actor,
104
+ published: question.published,
105
+ to: question.to, cc: question.cc,
106
+ object,
107
+ };
108
+ }
109
+
110
+ /**
111
+ * One vote on one of OUR polls, named by the option's title. Returns true
112
+ * when it counted — a second answer to a single-choice poll, an option we do
113
+ * not offer, or a poll that has closed all count for nothing.
114
+ */
115
+ export async function recordVote(publisher, questionId, actor, optionName) {
116
+ const s = publisher.store.getStatuses().find(x => x.noteId === questionId);
117
+ if (!s?.poll || s.kind !== 'post' || s.actor !== publisher.urls.actor) return false;
118
+ if (polls.pollClosed(s.poll)) return false;
119
+ const index = polls.optionIndex(s.poll, optionName);
120
+ if (index < 0) return false;
121
+ const all = publisher.store.read(polls.VOTES_DOC, {});
122
+ const { roster, changed } = polls.addVote(all[questionId] || {}, polls.voterKey(actor), index,
123
+ { multiple: !!s.poll.multiple });
124
+ if (!changed) return false;
125
+ publisher.store.write(polls.VOTES_DOC, { ...all, [questionId]: roster });
126
+ publisher.store.updateStatus(questionId, { poll: polls.withTally(s.poll, roster) });
127
+ publisher._pollDirty(questionId);
128
+ return true;
129
+ }
130
+
131
+ // Open the rewrite window for a poll whose count moved. Already open is
132
+ // already enough: the whole point is that a burst costs one rewrite.
133
+ export function pollDirty(publisher, questionId) {
134
+ if (publisher.pollTimers.has(questionId)) return;
135
+ const t = setTimeout(() => {
136
+ publisher.pollTimers.delete(questionId);
137
+ publisher.republishPoll(questionId).catch(e => publisher.log(`poll rewrite: ${e.message}`));
138
+ }, POLL_REWRITE_MS);
139
+ t.unref?.();
140
+ publisher.pollTimers.set(questionId, t);
141
+ }
142
+
143
+ /**
144
+ * Write the poll's current count back to the pod and tell everyone who has
145
+ * it. `closing` stamps it shut, which is a one-way door.
146
+ */
147
+ export async function republishPoll(publisher, questionId, { closing = null } = {}) {
148
+ const s = publisher.store.getStatuses().find(x => x.noteId === questionId);
149
+ if (!s?.poll) return null;
150
+ const { urls } = publisher;
151
+ const roster = publisher.store.read(polls.VOTES_DOC, {})[questionId] || {};
152
+ // A shut poll whose roster has been retired keeps the counts on its row:
153
+ // deriving them from an empty roster would publish a poll nobody voted in.
154
+ const shut = closing || s.poll.closed;
155
+ const counted = shut && !Object.keys(roster).length ? s.poll : polls.withTally(s.poll, roster);
156
+ const poll = { ...counted, ...(closing ? { closed: closing } : {}) };
157
+ const container = String(s.noteId).startsWith(urls.privateNotes) ? urls.privateNotes : urls.notes;
158
+ const slug = s.slug || String(s.noteId).slice(container.length);
159
+ const mentions = (s.mentions || []).map(m => ({
160
+ handle: String(m.name || '').replace(/^@/, ''), actor: m.href, page: null, inbox: null,
161
+ }));
162
+ const question = wire.questionDoc({
163
+ urls, slug, content: s.text ?? '', published: s.published, inReplyTo: s.inReplyTo,
164
+ attachments: [], mentions,
165
+ visibility: s.visibility || 'public', summary: s.spoiler || null, container,
166
+ options: poll.options, multiple: !!poll.multiple, endTime: poll.expiresAt,
167
+ closed: poll.closed, votersCount: poll.votersCount || 0,
168
+ });
169
+ await podNotes.write(publisher.remote, question.id, question);
170
+ // The Create is overwritten too, so a group's Announce resolves to the
171
+ // current count rather than to the one the poll opened with.
172
+ await podNotes.writeCreate(publisher.remote, wire.createActivityId(question.id),
173
+ publisher._pollActivity('Create', question, wire.createActivityId(question.id)));
174
+ publisher.store.updateStatus(questionId, { poll });
175
+
176
+ // Not `updated`: a changed count is not an edit, and stamping one would
177
+ // have every client show the poll as edited each time somebody voted.
178
+ //
179
+ // The Update is named after the STATE it carries rather than the moment it
180
+ // was sent. A clock only tells two rewrites apart when they fall in
181
+ // different milliseconds, and a receiving server that has seen an activity
182
+ // id drops the next one wearing it — which would quietly freeze the count.
183
+ // Naming the state means an id changes exactly when there is something new
184
+ // to say, and two sends of the same numbers are the duplicate they look
185
+ // like.
186
+ const stamp = crypto.createHash('sha256').update(JSON.stringify([
187
+ poll.options.map(o => o.votes || 0), poll.votersCount || 0, poll.closed || '',
188
+ ])).digest('hex').slice(0, 12);
189
+ const update = publisher._pollActivity('Update', question, `${question.id}#poll-${stamp}`);
190
+ const contacts = publisher.store.getContacts();
191
+ const inboxes = [...new Set([
192
+ ...(s.visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
193
+ ...(s.poll.mentionInboxes || []),
194
+ ].filter(Boolean))];
195
+ await publisher.deliverer.deliverToAll(inboxes, update);
196
+ publisher.log(`poll ${closing ? 'closed' : 'count published'}: ${question.id} → ${inboxes.length} inbox(es)`);
197
+ return poll;
198
+ }
199
+
200
+ /**
201
+ * Shut any poll whose time is up. Called from the agent's sweep. Closing is
202
+ * recorded BEFORE the republish, so a failed republish cannot leave a poll
203
+ * open and collecting votes it has already refused.
204
+ */
205
+ export async function closeDuePolls(publisher, now = Date.now()) {
206
+ const due = publisher.store.getStatuses().filter(s => s.kind === 'post' && s.poll
207
+ && !s.poll.closed && s.poll.expiresAt && Date.parse(s.poll.expiresAt) <= now);
208
+ for (const s of due) {
209
+ const closed = new Date(Math.min(now, Date.parse(s.poll.expiresAt) || now)).toISOString();
210
+ clearTimeout(publisher.pollTimers.get(s.noteId));
211
+ publisher.pollTimers.delete(s.noteId);
212
+ publisher.store.updateStatus(s.noteId, { poll: { ...s.poll, closed } });
213
+ const done = await publisher.republishPoll(s.noteId, { closing: closed })
214
+ .catch(e => { publisher.log(`poll close ${s.noteId}: ${e.message}`); return null; });
215
+ // The roster only ever answered one question — has this person already
216
+ // voted — and a shut poll has stopped asking it. Dropping it keeps a
217
+ // document we serialize whole from carrying every poll's voters forever.
218
+ if (done) {
219
+ const all = publisher.store.read(polls.VOTES_DOC, {});
220
+ if (all[s.noteId]) {
221
+ delete all[s.noteId];
222
+ publisher.store.write(polls.VOTES_DOC, all);
223
+ }
224
+ }
225
+ }
226
+ return due.length;
227
+ }
228
+
229
+ /** Stop the pending rewrite windows. Called at shutdown. */
230
+ export function stopPolls(publisher) {
231
+ for (const t of publisher.pollTimers.values()) clearTimeout(t);
232
+ publisher.pollTimers.clear();
233
+ }
@@ -0,0 +1,199 @@
1
+ // restore.mjs — a machine that is behind the pod catching up: followers and
2
+ // outbox entries the pod carries and this machine never heard of, and the
3
+ // actor's own posts rebuilt from the published outbox.
4
+
5
+ import * as wire from '../wire.mjs';
6
+ import * as podNotes from '../../pod/notes.mjs';
7
+ import { readLenient } from '../as2.mjs';
8
+
9
+ const ACCEPT_AP = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
10
+ const REBUILD_MAX_PER_RUN = 200;
11
+
12
+ // Anyone the pod says follows us that we have no record of, and never
13
+ // deliberately removed. In the steady state there is nobody: the pod's list is
14
+ // written from this one. They appear when the local half is BEHIND the pod —
15
+ // a restored backup, a home copied off a dead machine — and republishing
16
+ // blindly would delete them from the wire and, worse, stop delivering to them.
17
+ //
18
+ // Removals are what makes this safe to do unconditionally: `dropFollower`
19
+ // records every unfollow, ejection and account deletion, so a returning name
20
+ // is either genuinely still a follower or genuinely a mistake we made.
21
+ export async function reconcileFollowers(publisher, contacts) {
22
+ let published = [];
23
+ try {
24
+ published = (await publisher.readPublishedFollowers()) || []; // walks pages
25
+ } catch { return 0; } // no list to reconcile against
26
+
27
+ const known = new Set(contacts.followers.map(f => f.actor));
28
+ const removed = new Set((contacts.removedFollowers || []).map(r => r.actor));
29
+ const missing = published.filter(a => typeof a === 'string' && !known.has(a) && !removed.has(a));
30
+ if (!missing.length) return 0;
31
+
32
+ // An inbox is what delivery needs, and only the actor document has it — so
33
+ // recovering a follower costs one fetch each. Capped: a list this wrong is
34
+ // a restore, and the rest catch up on the next publish.
35
+ let recovered = 0;
36
+ for (const actor of missing.slice(0, 200)) {
37
+ try {
38
+ const res = await publisher.deliverer.signedFetch(actor, { headers: { accept: ACCEPT_AP } });
39
+ if (!res.ok) continue;
40
+ // The graph, not the compacted copy: `inbox` is `ldp:inbox` rather than
41
+ // an `as:` term, and what is wanted here is the statement, not a key.
42
+ const { doc, view, degraded } = await readLenient(await res.json());
43
+ const actorDoc = view ?? doc;
44
+ if (degraded) publisher.log?.(`actor ${actor} grounded to read: ${degraded}`);
45
+ if (!actorDoc?.inbox) continue;
46
+ contacts.followers.push({
47
+ actor, inbox: actorDoc.inbox, sharedInbox: actorDoc.endpoints?.sharedInbox || null, recovered: true,
48
+ // Said explicitly, because onUndo reads it: the pod publishes WHO
49
+ // follows, never the id of the Follow that did it, so a recovered
50
+ // record has nothing an Undo can be matched against and must not be
51
+ // evictable by one naming anything at all.
52
+ followId: null,
53
+ });
54
+ recovered++;
55
+ } catch { /* unreachable now; it will be there next time */ }
56
+ }
57
+ if (recovered) {
58
+ publisher.store.setContacts(contacts);
59
+ publisher.log(`reconciled ${recovered} follower(s) the pod knew about and this machine did not `
60
+ + '— a restored or copied state was behind');
61
+ }
62
+ return recovered;
63
+ }
64
+
65
+ // The same argument as reconcileFollowers, for the other published list. It
66
+ // matters more: the outbox is the INDEX a statuses rebuild reads, so a
67
+ // republish from a restored-and-behind machine would destroy the record of
68
+ // everything this actor ever posted — and destroy it before anyone noticed
69
+ // there was anything to recover.
70
+ //
71
+ // Safe for the same reason: `unrecordOutbox` leaves a tombstone, so an entry
72
+ // the pod still carries is one this machine has not heard of, never one it
73
+ // deliberately took back.
74
+ export async function reconcileOutbox(publisher, outbox) {
75
+ let published = [];
76
+ try {
77
+ published = await publisher.readPublishedOutbox() || [];
78
+ } catch { return 0; }
79
+ if (!Array.isArray(published) || !published.length) return 0;
80
+
81
+ const idOf = (i) => (typeof i === 'string' ? i : i?.id || null);
82
+ const known = new Set(outbox.map(idOf).filter(Boolean));
83
+ const removed = new Set((publisher.store.read('outbox-removed.json', [])).map(r => r.id));
84
+ const missing = published.filter((i) => {
85
+ const id = idOf(i);
86
+ return id && !known.has(id) && !removed.has(id);
87
+ });
88
+ if (!missing.length) return 0;
89
+ // Newest first, like recordOutbox leaves it; the pod's copy is already in
90
+ // that order, so appending the tail is enough to keep both sorted.
91
+ outbox.push(...missing);
92
+ publisher.store.write('outbox.json', outbox);
93
+ publisher.log(`reconciled ${missing.length} outbox entr(ies) the pod carried and this machine did not`);
94
+ return missing.length;
95
+ }
96
+
97
+ // Recover this actor's own posts from the pod's public face. The private half
98
+ // lives on this machine now, so a restored backup or a replaced machine loses
99
+ // statuses.json while the pod still serves every note. Followers already come
100
+ // back; this is the other half of the same gap.
101
+ //
102
+ // The INDEX is ap/outbox, not the ap/notes/ listing, and that difference is
103
+ // the safety argument. Deleting a post rewrites the outbox in one PUT, so an
104
+ // entry still there is a post that still stands. The note DOCUMENT can outlive
105
+ // its own deletion — deleteNote's `remote.delete` is a request that can fail —
106
+ // so walking the container can bring back something its author took down.
107
+ // `fromNotes` is for when you would rather have that than lose the post; it is
108
+ // not the default, and it says so where it is offered.
109
+ //
110
+ // MERGE ONLY. A status this machine already holds is left exactly as it is:
111
+ // it carries local facts — favourited, reblogged, the activities an Undo has
112
+ // to name — that the pod knows nothing about. That is also what makes the
113
+ // failure modes harmless: a listing that fails returns nothing, and nothing
114
+ // is what an empty listing recovers.
115
+ export async function rebuildStatuses(publisher, { fromNotes = false } = {}) {
116
+ const { urls } = publisher;
117
+ const ids = new Set();
118
+ const boosts = [];
119
+ let indexed = false;
120
+
121
+ const published = await publisher.readPublishedOutbox().catch(() => null);
122
+ if (published) {
123
+ indexed = true;
124
+ for (const item of published) {
125
+ if (typeof item === 'string') { if (item.startsWith(urls.notes)) ids.add(item); }
126
+ else if (item?.type === 'Announce') boosts.push(item);
127
+ }
128
+ }
129
+ if (fromNotes) {
130
+ // Three documents are published per post — the note, `-create` and
131
+ // `-replies` — plus a `.keep`. Which is which is settled by reading the
132
+ // document below, not by its name: a slug is a date and eight hex
133
+ // characters and says nothing about what it holds.
134
+ for (const child of await podNotes.list(publisher.remote, urls).catch(() => [])) {
135
+ ids.add(child.url);
136
+ }
137
+ indexed = true;
138
+ }
139
+ if (!indexed) return { indexed: 0, recovered: 0, reblogs: 0, landed: false, why: 'the pod would not answer for its outbox' };
140
+
141
+ const statuses = publisher.store.getStatuses();
142
+ const have = new Set(statuses.map(s => s.noteId));
143
+ const removed = new Set(publisher.store.read('outbox-removed.json', []).map(r => r.id));
144
+ const recovered = [];
145
+ // Capped per run: a long-lived actor's rebuild is otherwise one pod request
146
+ // per post it has ever made, in one burst. What is left is picked up by
147
+ // running it again — the merge is idempotent, so that is safe to repeat.
148
+ let budget = REBUILD_MAX_PER_RUN;
149
+ for (const id of ids) {
150
+ if (budget <= 0) { publisher.log(`rebuild: stopping at ${REBUILD_MAX_PER_RUN} this run — run it again for the rest`); break; }
151
+ if (have.has(id) || removed.has(id)) continue;
152
+ budget--;
153
+ const note = await podNotes.read(publisher.remote, id).catch(() => null);
154
+ if (note?.type !== 'Note' || note.id !== id || note.attributedTo !== urls.actor) continue;
155
+ const attachments = wire.attachmentsOf(note);
156
+ const mentions = (Array.isArray(note.tag) ? note.tag : [])
157
+ .filter(t => t?.type === 'Mention' && t.href)
158
+ .map(t => ({ href: t.href, name: t.name }));
159
+ recovered.push({
160
+ noteId: note.id, actor: urls.actor, content: note.content || '',
161
+ published: note.published || null,
162
+ ...(note.inReplyTo ? { inReplyTo: note.inReplyTo } : {}),
163
+ kind: 'post', slug: note.id.slice(urls.notes.length),
164
+ ...(attachments.length ? { attachments } : {}),
165
+ ...(mentions.length ? { mentions } : {}),
166
+ recovered: true,
167
+ });
168
+ }
169
+
170
+ // A boost is only recoverable for a post we can name — the Announce carries
171
+ // the activity a later Undo needs, but not the boosted post's text, which
172
+ // belongs to whoever wrote it.
173
+ const merged = [...statuses, ...recovered];
174
+ let reblogs = 0;
175
+ for (const act of boosts) {
176
+ const object = typeof act.object === 'string' ? act.object : act.object?.id;
177
+ const s = object && merged.find(x => x.noteId === object);
178
+ if (!s || s.reblogged) continue;
179
+ s.reblogged = true;
180
+ s.announceActivity = act;
181
+ reblogs++;
182
+ }
183
+ if (!recovered.length && !reblogs) return { indexed: ids.size, recovered: 0, reblogs: 0, landed: true };
184
+
185
+ // Written whole and sorted: addStatus unshifts and fires the streaming
186
+ // event, so a loop of it would arrive backwards and push every recovered
187
+ // post at connected clients as new.
188
+ merged.sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
189
+ const kept = merged.slice(0, 1000);
190
+ publisher.store.write('statuses.json', kept);
191
+ const landed = await publisher.store.commit();
192
+
193
+ publisher.log(`rebuilt ${recovered.length} post(s) and ${reblogs} boost(s) from the pod`
194
+ + `${landed ? '' : ' — THE STATE WRITE DID NOT LAND'}`);
195
+ return {
196
+ indexed: ids.size, recovered: recovered.length, reblogs, landed,
197
+ dropped: Math.max(0, merged.length - kept.length),
198
+ };
199
+ }