fedipod-server 0.8.0 → 0.10.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.
package/lib/publisher.mjs CHANGED
@@ -7,6 +7,7 @@ import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import * as wire from './wire.mjs';
10
+ import * as polls from './polls.mjs';
10
11
  import { USER_AGENT } from './ua.mjs';
11
12
  import { HTTP_TIMEOUT_MS } from './safefetch.mjs';
12
13
 
@@ -16,6 +17,11 @@ const ACCEPT_AP = 'application/activity+json, application/ld+json; profile="http
16
17
  // still gets everything, so a missed call site degrades to the old cost rather
17
18
  // than silently publishing nothing.
18
19
  const REBUILD_MAX_PER_RUN = 200;
20
+ // How long a poll gathers votes before its Question is rewritten. Every vote
21
+ // changes a number other servers re-read, and rewriting per vote would make a
22
+ // busy poll a steady write stream against the pod. A burst costs one rewrite
23
+ // and one Update instead.
24
+ const POLL_REWRITE_MS = 10_000;
19
25
  const ALL_COLLECTIONS = { followers: true, following: true, outbox: true, acls: true,
20
26
  pending: true, blocked: true };
21
27
  const AGENT_VERSION = JSON.parse(fs.readFileSync(
@@ -23,7 +29,7 @@ const AGENT_VERSION = JSON.parse(fs.readFileSync(
23
29
 
24
30
  export class Publisher {
25
31
  constructor({ config, remote, local, store, deliverer, publicKeyPem, assertionKey = null, log = console.log,
26
- probeFetch = null, resolveMention = null, privateOnPod = true,
32
+ probeFetch = null, resolveMention = null, privateOnPod = true, clientOrigin = null,
27
33
  }) {
28
34
  this.config = config;
29
35
  this.remote = remote;
@@ -32,6 +38,10 @@ export class Publisher {
32
38
  this.deliverer = deliverer;
33
39
  this.publicKeyPem = publicKeyPem;
34
40
  this.assertionKey = assertionKey; // Ed25519 public half, multibase; null = no proofs
41
+ // Where this identity's client surface answers, when that is an address a
42
+ // stranger can reach. Null on a laptop, where the surface is on loopback
43
+ // and advertising it to the world would name somewhere nobody can go.
44
+ this.clientOrigin = clientOrigin;
35
45
  // A fronted identity (config.gateway.frontActor) advertises its ids on a
36
46
  // shared domain; the map tells RemotePod where each writes on the pod.
37
47
  const publicBase = config.gateway?.frontActor
@@ -44,6 +54,8 @@ export class Publisher {
44
54
  this.probeFetch = probeFetch || ((u, i) => this.remote.probe(u, i));
45
55
  this.resolveMention = resolveMention;
46
56
  this.privateOnPod = privateOnPod;
57
+ // Per-poll rewrite windows, keyed by question id. See POLL_REWRITE_MS.
58
+ this.pollTimers = new Map();
47
59
  this.log = log;
48
60
  }
49
61
 
@@ -88,6 +100,13 @@ export class Publisher {
88
100
  pendingFollowing: priv ? urls.pendingFollowing : null,
89
101
  blocked: priv ? urls.blocked : null,
90
102
  inbox: gwActive ? gw.url : null,
103
+ // The agent's own outbox endpoint, where it is reachable: a client
104
+ // following the actor must arrive somewhere that will take a write.
105
+ outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : null,
106
+ // How a client-to-server client finds the way in with nothing configured
107
+ // by hand. Advertised only where the surface is publicly reachable.
108
+ oauthAuthorize: this.clientOrigin ? `${this.clientOrigin}oauth/authorize` : null,
109
+ oauthToken: this.clientOrigin ? `${this.clientOrigin}oauth/token` : null,
91
110
  });
92
111
  const surface = crypto.createHash('sha256').update(JSON.stringify({
93
112
  actor: actorDoc, handle: this.config.handle, host,
@@ -872,20 +891,13 @@ export class Publisher {
872
891
  }
873
892
 
874
893
  // Compose → wire note on remote pod + RDF truth locally + deliver Create.
875
- async publishNote(content, { inReplyTo, attachments, visibility = 'public', spoilerText = null } = {}) {
876
- const { urls } = this;
877
- const priv = visibility === 'private' || visibility === 'direct';
878
- if (priv) {
879
- const ready = await this.privateReady();
880
- if (ready !== true) throw new Error(ready);
881
- }
882
- const published = new Date().toISOString();
883
- const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
884
- // A mention nobody can resolve stays plain text rather than failing the post.
885
- // The reply's own text decides who is mentioned: trim a handle out and that
886
- // person is not notified, which is what every fediverse client leads people
887
- // to expect. A Group named in the parent is the one thing carried forward
888
- // regardless — drop it and the group stops carrying the thread.
894
+ // Who this text mentions, resolved. A mention nobody can resolve stays plain
895
+ // text rather than failing the post. The text itself decides: trim a handle
896
+ // out and that person is not notified, which is what every fediverse client
897
+ // leads people to expect. A Group named in the parent is the one thing
898
+ // carried forward regardless — drop it and the group stops carrying the
899
+ // thread.
900
+ async _mentionsFor(content, inReplyTo) {
889
901
  const inText = new Set(wire.mentionsIn(content));
890
902
  const carried = inReplyTo
891
903
  ? (this.store.getStatuses().find(s => s.noteId === inReplyTo)?.mentions || [])
@@ -899,6 +911,19 @@ export class Publisher {
899
911
  if (!inText.has(handle) && doc.type !== 'Group') continue; // author trimmed them out
900
912
  mentions.push({ handle, actor: doc.id, page: doc.url || null, inbox: doc.endpoints?.sharedInbox || doc.inbox });
901
913
  }
914
+ return mentions;
915
+ }
916
+
917
+ async publishNote(content, { inReplyTo, attachments, visibility = 'public', spoilerText = null } = {}) {
918
+ const { urls } = this;
919
+ const priv = visibility === 'private' || visibility === 'direct';
920
+ if (priv) {
921
+ const ready = await this.privateReady();
922
+ if (ready !== true) throw new Error(ready);
923
+ }
924
+ const published = new Date().toISOString();
925
+ const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
926
+ const mentions = await this._mentionsFor(content, inReplyTo);
902
927
  const note = wire.noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions,
903
928
  visibility, summary: spoilerText, container: priv ? urls.privateNotes : urls.notes });
904
929
 
@@ -951,6 +976,232 @@ export class Publisher {
951
976
  return note;
952
977
  }
953
978
 
979
+ // --- polls ---------------------------------------------------------------
980
+ //
981
+ // A poll is published as a Question and answered by ordinary replies naming
982
+ // an option, so the count is ours to keep and ours to republish. The roster
983
+ // of who chose what lives in agent state (polls.VOTES_DOC); the tallies on
984
+ // the status row and in the pod document are always derived from it.
985
+
986
+ /**
987
+ * Publish a poll. `options` is a list of choice titles, `multiple` lets a
988
+ * voter pick more than one, and `expiresAt` is when voting stops.
989
+ */
990
+ async publishQuestion(content, { options = [], multiple = false, expiresAt = null,
991
+ inReplyTo = undefined, visibility = 'public', spoilerText = null } = {}) {
992
+ const { urls } = this;
993
+ const priv = visibility === 'private' || visibility === 'direct';
994
+ if (priv) {
995
+ const ready = await this.privateReady();
996
+ if (ready !== true) throw new Error(ready);
997
+ }
998
+ const titles = [].concat(options).map(o => String(o ?? '').trim()).filter(Boolean);
999
+ if (titles.length < 2) throw new Error('a poll needs at least two options');
1000
+ // Options are matched BY NAME when a vote arrives — that is the whole of
1001
+ // the convention — so two options reading the same are one option that
1002
+ // cannot be told apart.
1003
+ if (new Set(titles).size !== titles.length) throw new Error('a poll’s options must differ from one another');
1004
+ // Bounded here as well as at the client API, because the outbox is a
1005
+ // second way in and an unbounded poll is a document a stranger sizes.
1006
+ if (titles.length > polls.MAX_OPTIONS) throw new Error(`a poll takes at most ${polls.MAX_OPTIONS} options`);
1007
+ if (titles.some(t => t.length > polls.MAX_OPTION_CHARS)) {
1008
+ throw new Error(`a poll option is at most ${polls.MAX_OPTION_CHARS} characters`);
1009
+ }
1010
+ if (expiresAt) {
1011
+ const ends = Date.parse(expiresAt);
1012
+ if (!Number.isFinite(ends)) throw new Error('the closing time is not a date');
1013
+ const seconds = (ends - Date.now()) / 1000;
1014
+ if (seconds < polls.MIN_SECONDS || seconds > polls.MAX_SECONDS) {
1015
+ throw new Error(`a poll runs between ${polls.MIN_SECONDS} and ${polls.MAX_SECONDS} seconds`);
1016
+ }
1017
+ }
1018
+
1019
+ const published = new Date().toISOString();
1020
+ const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
1021
+ const mentions = await this._mentionsFor(content, inReplyTo);
1022
+ const poll = {
1023
+ multiple: !!multiple,
1024
+ expiresAt: expiresAt || null,
1025
+ closed: null,
1026
+ options: titles.map(title => ({ title, votes: 0 })),
1027
+ votersCount: 0,
1028
+ // Resolved once, here: a tally rewrite must not cost a webfinger lookup
1029
+ // per vote for people the poll named.
1030
+ mentionInboxes: [...new Set(mentions.map(m => m.inbox).filter(Boolean))],
1031
+ };
1032
+ const question = wire.questionDoc({
1033
+ urls, slug, content, published, inReplyTo, attachments: [], mentions,
1034
+ visibility, summary: spoilerText,
1035
+ container: priv ? urls.privateNotes : urls.notes,
1036
+ options: poll.options, multiple: poll.multiple, endTime: poll.expiresAt, votersCount: 0,
1037
+ });
1038
+
1039
+ await this.remote.putJson(question.id, question);
1040
+ // Empty, but present: a dangling `replies` that 404s is worse than none.
1041
+ await this.remote.putJson(wire.repliesId(question.id), wire.collection(wire.repliesId(question.id), []));
1042
+ if (!priv) await this.recordOutbox(question.id);
1043
+ await this.local.writeNote('posts', slug, {
1044
+ noteId: question.id, actor: urls.actor, published, content: question.content,
1045
+ inReplyTo, attachments: [],
1046
+ });
1047
+ this.store.addStatus({
1048
+ noteId: question.id, actor: urls.actor, content: question.content, published,
1049
+ kind: 'post', slug, text: content, visibility, poll, inReplyTo,
1050
+ ...(spoilerText ? { spoiler: spoilerText } : {}),
1051
+ ...(question.tag?.length ? { mentions: question.tag.map(t => ({ href: t.href, name: t.name })) } : {}),
1052
+ });
1053
+
1054
+ const create = this._pollActivity('Create', question, wire.createActivityId(question.id));
1055
+ await this.remote.putJson(create.id, create);
1056
+ const contacts = this.store.getContacts();
1057
+ const inboxes = [...new Set([
1058
+ ...(visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
1059
+ ...poll.mentionInboxes,
1060
+ ].filter(Boolean))];
1061
+ await this.deliverer.deliverToAll(inboxes, create);
1062
+ this.log(`poll published: ${question.id} (${titles.length} options) → ${inboxes.length} inbox(es)`);
1063
+ return question;
1064
+ }
1065
+
1066
+ // The Create or Update carrying a Question. The context is hoisted onto the
1067
+ // activity and the embedded object keeps none: a nested @context is legal
1068
+ // JSON-LD but not every server reads one, and votersCount is declared there.
1069
+ _pollActivity(type, question, id) {
1070
+ const { '@context': ctx, ...object } = question;
1071
+ return {
1072
+ '@context': ctx, id, type,
1073
+ actor: this.urls.actor,
1074
+ published: question.published,
1075
+ to: question.to, cc: question.cc,
1076
+ object,
1077
+ };
1078
+ }
1079
+
1080
+ /**
1081
+ * One vote on one of OUR polls, named by the option's title. Returns true
1082
+ * when it counted — a second answer to a single-choice poll, an option we do
1083
+ * not offer, or a poll that has closed all count for nothing.
1084
+ */
1085
+ async recordVote(questionId, actor, optionName) {
1086
+ const s = this.store.getStatuses().find(x => x.noteId === questionId);
1087
+ if (!s?.poll || s.kind !== 'post' || s.actor !== this.urls.actor) return false;
1088
+ if (polls.pollClosed(s.poll)) return false;
1089
+ const index = polls.optionIndex(s.poll, optionName);
1090
+ if (index < 0) return false;
1091
+ const all = this.store.read(polls.VOTES_DOC, {});
1092
+ const { roster, changed } = polls.addVote(all[questionId] || {}, polls.voterKey(actor), index,
1093
+ { multiple: !!s.poll.multiple });
1094
+ if (!changed) return false;
1095
+ this.store.write(polls.VOTES_DOC, { ...all, [questionId]: roster });
1096
+ this.store.updateStatus(questionId, { poll: polls.withTally(s.poll, roster) });
1097
+ this._pollDirty(questionId);
1098
+ return true;
1099
+ }
1100
+
1101
+ // Open the rewrite window for a poll whose count moved. Already open is
1102
+ // already enough: the whole point is that a burst costs one rewrite.
1103
+ _pollDirty(questionId) {
1104
+ if (this.pollTimers.has(questionId)) return;
1105
+ const t = setTimeout(() => {
1106
+ this.pollTimers.delete(questionId);
1107
+ this.republishPoll(questionId).catch(e => this.log(`poll rewrite: ${e.message}`));
1108
+ }, POLL_REWRITE_MS);
1109
+ t.unref?.();
1110
+ this.pollTimers.set(questionId, t);
1111
+ }
1112
+
1113
+ /**
1114
+ * Write the poll's current count back to the pod and tell everyone who has
1115
+ * it. `closing` stamps it shut, which is a one-way door.
1116
+ */
1117
+ async republishPoll(questionId, { closing = null } = {}) {
1118
+ const s = this.store.getStatuses().find(x => x.noteId === questionId);
1119
+ if (!s?.poll) return null;
1120
+ const { urls } = this;
1121
+ const roster = this.store.read(polls.VOTES_DOC, {})[questionId] || {};
1122
+ // A shut poll whose roster has been retired keeps the counts on its row:
1123
+ // deriving them from an empty roster would publish a poll nobody voted in.
1124
+ const shut = closing || s.poll.closed;
1125
+ const counted = shut && !Object.keys(roster).length ? s.poll : polls.withTally(s.poll, roster);
1126
+ const poll = { ...counted, ...(closing ? { closed: closing } : {}) };
1127
+ const container = String(s.noteId).startsWith(urls.privateNotes) ? urls.privateNotes : urls.notes;
1128
+ const slug = s.slug || String(s.noteId).slice(container.length);
1129
+ const mentions = (s.mentions || []).map(m => ({
1130
+ handle: String(m.name || '').replace(/^@/, ''), actor: m.href, page: null, inbox: null,
1131
+ }));
1132
+ const question = wire.questionDoc({
1133
+ urls, slug, content: s.text ?? '', published: s.published, inReplyTo: s.inReplyTo,
1134
+ attachments: [], mentions,
1135
+ visibility: s.visibility || 'public', summary: s.spoiler || null, container,
1136
+ options: poll.options, multiple: !!poll.multiple, endTime: poll.expiresAt,
1137
+ closed: poll.closed, votersCount: poll.votersCount || 0,
1138
+ });
1139
+ await this.remote.putJson(question.id, question);
1140
+ // The Create is overwritten too, so a group's Announce resolves to the
1141
+ // current count rather than to the one the poll opened with.
1142
+ await this.remote.putJson(wire.createActivityId(question.id),
1143
+ this._pollActivity('Create', question, wire.createActivityId(question.id)));
1144
+ this.store.updateStatus(questionId, { poll });
1145
+
1146
+ // Not `updated`: a changed count is not an edit, and stamping one would
1147
+ // have every client show the poll as edited each time somebody voted.
1148
+ //
1149
+ // The Update is named after the STATE it carries rather than the moment it
1150
+ // was sent. A clock only tells two rewrites apart when they fall in
1151
+ // different milliseconds, and a receiving server that has seen an activity
1152
+ // id drops the next one wearing it — which would quietly freeze the count.
1153
+ // Naming the state means an id changes exactly when there is something new
1154
+ // to say, and two sends of the same numbers are the duplicate they look
1155
+ // like.
1156
+ const stamp = crypto.createHash('sha256').update(JSON.stringify([
1157
+ poll.options.map(o => o.votes || 0), poll.votersCount || 0, poll.closed || '',
1158
+ ])).digest('hex').slice(0, 12);
1159
+ const update = this._pollActivity('Update', question, `${question.id}#poll-${stamp}`);
1160
+ const contacts = this.store.getContacts();
1161
+ const inboxes = [...new Set([
1162
+ ...(s.visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
1163
+ ...(s.poll.mentionInboxes || []),
1164
+ ].filter(Boolean))];
1165
+ await this.deliverer.deliverToAll(inboxes, update);
1166
+ this.log(`poll ${closing ? 'closed' : 'count published'}: ${question.id} → ${inboxes.length} inbox(es)`);
1167
+ return poll;
1168
+ }
1169
+
1170
+ /**
1171
+ * Shut any poll whose time is up. Called from the agent's sweep. Closing is
1172
+ * recorded BEFORE the republish, so a failed republish cannot leave a poll
1173
+ * open and collecting votes it has already refused.
1174
+ */
1175
+ async closeDuePolls(now = Date.now()) {
1176
+ const due = this.store.getStatuses().filter(s => s.kind === 'post' && s.poll
1177
+ && !s.poll.closed && s.poll.expiresAt && Date.parse(s.poll.expiresAt) <= now);
1178
+ for (const s of due) {
1179
+ const closed = new Date(Math.min(now, Date.parse(s.poll.expiresAt) || now)).toISOString();
1180
+ clearTimeout(this.pollTimers.get(s.noteId));
1181
+ this.pollTimers.delete(s.noteId);
1182
+ this.store.updateStatus(s.noteId, { poll: { ...s.poll, closed } });
1183
+ const done = await this.republishPoll(s.noteId, { closing: closed })
1184
+ .catch(e => { this.log(`poll close ${s.noteId}: ${e.message}`); return null; });
1185
+ // The roster only ever answered one question — has this person already
1186
+ // voted — and a shut poll has stopped asking it. Dropping it keeps a
1187
+ // document we serialize whole from carrying every poll's voters forever.
1188
+ if (done) {
1189
+ const all = this.store.read(polls.VOTES_DOC, {});
1190
+ if (all[s.noteId]) {
1191
+ delete all[s.noteId];
1192
+ this.store.write(polls.VOTES_DOC, all);
1193
+ }
1194
+ }
1195
+ }
1196
+ return due.length;
1197
+ }
1198
+
1199
+ /** Stop the pending rewrite windows. Called at shutdown. */
1200
+ stopPolls() {
1201
+ for (const t of this.pollTimers.values()) clearTimeout(t);
1202
+ this.pollTimers.clear();
1203
+ }
1204
+
954
1205
  // The pinned posts, as the actor's featured collection — the one document a
955
1206
  // remote server reads when it shows this profile's pins.
956
1207
  async publishFeatured() {
package/lib/social.mjs CHANGED
@@ -317,6 +317,8 @@ export async function votePoll(agent, s, choices) {
317
317
  return { ok: false, error: 'this poll has closed' };
318
318
  }
319
319
  if (poll.voted) return { ok: false, error: 'already voted' };
320
+ // Your own poll is the one you are counting, not one you are answering.
321
+ if (s.actor === urls.actor) return { ok: false, error: 'this is your own poll' };
320
322
  const picks = [...new Set(choices)].filter(i => i >= 0 && i < opts.length);
321
323
  if (!picks.length) return { ok: false, error: 'invalid choice' };
322
324
  if (!poll.multiple && picks.length > 1) return { ok: false, error: 'this poll takes one choice' };
package/lib/storage.mjs CHANGED
@@ -61,9 +61,15 @@ export class HttpStorage {
61
61
  return { notModified: false, names, etag: res.headers.get('etag') };
62
62
  }
63
63
 
64
- async read(p, { etag } = {}) {
64
+ // `accept` is for callers reading something that is RDF but is wanted as it
65
+ // was written: asking turtle-first for a JSON-LD document gets turtle back,
66
+ // because the server is entitled to convert between two RDF syntaxes.
67
+ async read(p, { etag, accept } = {}) {
65
68
  const res = await this.fetchImpl(this._url(p), {
66
- headers: { accept: 'text/turtle, application/json;q=0.9, */*;q=0.8', ...(etag ? { 'if-none-match': etag } : {}) },
69
+ headers: {
70
+ accept: accept || 'text/turtle, application/json;q=0.9, */*;q=0.8',
71
+ ...(etag ? { 'if-none-match': etag } : {}),
72
+ },
67
73
  });
68
74
  if (res.status === 304) return { ok: true, notModified: true, status: 304, body: null, etag };
69
75
  if (res.status >= 400) return { ok: false, notModified: false, status: res.status, body: null, etag: null };
package/lib/wire.mjs CHANGED
@@ -108,7 +108,7 @@ export const assertionKeyId = (urls) => urls.actor + '#ed25519-key';
108
108
  export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null, movedTo = null, kind = 'person',
109
109
  approveJoins = false, summary = null, icon = null, image = null, fields = [],
110
110
  webId = null, aliases = [], moderators = null, pendingFollowers = null, pendingFollowing = null,
111
- blocked = null, inbox = null }) {
111
+ blocked = null, inbox = null, outbox = null, oauthAuthorize = null, oauthToken = null }) {
112
112
  // manuallyApprovesFollowers is NOT in the base AS2 context, so it is declared
113
113
  // inline exactly as Mastodon declares it — and only when we actually use it.
114
114
  // It is what makes a client show "Request to follow" rather than "Follow" and
@@ -188,8 +188,23 @@ export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null
188
188
  inbox: inbox || urls.inbox,
189
189
  // Every Mastodon actor publishes one. With a single actor per pod ours is
190
190
  // just the inbox, but its absence is the non-standard thing.
191
- endpoints: { sharedInbox: inbox || urls.inbox },
192
- outbox: urls.outbox,
191
+ //
192
+ // The two OAuth entries are how a client-to-server client finds its way in
193
+ // without being told anything by hand (ActivityPub 4.1). They appear only
194
+ // where this identity's client surface answers on an address a stranger
195
+ // can reach, which is why the caller supplies them rather than this
196
+ // building them from the pod URL.
197
+ endpoints: {
198
+ sharedInbox: inbox || urls.inbox,
199
+ ...(oauthAuthorize ? { oauthAuthorizationEndpoint: oauthAuthorize } : {}),
200
+ ...(oauthToken ? { oauthTokenEndpoint: oauthToken } : {}),
201
+ },
202
+ // Where a client sends what this actor writes, which the protocol says is
203
+ // this same address. The pod holds the collection and answers reads of it;
204
+ // a client-to-server write has to reach the agent, and the pod cannot take
205
+ // one. So where the agent is reachable it is named here, and a read of it
206
+ // is sent straight on to the pod's own document.
207
+ outbox: outbox || urls.outbox,
193
208
  // The pinned posts, as the collection other servers read when they show
194
209
  // this profile. Mastodon's term, declared the way Mastodon declares it.
195
210
  featured: urls.featured,
@@ -645,6 +660,45 @@ export function noteDoc({ urls, slug, content, published, inReplyTo, attachments
645
660
  return note;
646
661
  }
647
662
 
663
+ // A poll is a note that asks something: the same addressing, the same
664
+ // mentions, the same replies collection, with the choices in `oneOf` (pick
665
+ // one) or `anyOf` (pick several). Each choice carries the count as the
666
+ // totalItems of its own replies collection, which is where every server that
667
+ // shows a poll reads it from.
668
+ //
669
+ // `endTime` is when voting stops and `closed` is the stamp saying it has —
670
+ // both plain AS2. `votersCount` is Mastodon's: PEOPLE rather than answers,
671
+ // which differ only once a poll takes several answers each. It is not in the
672
+ // base context, so it is declared inline exactly as Mastodon declares it, and
673
+ // only when we actually carry it.
674
+ export function questionDoc({ options = [], multiple = false, endTime = null,
675
+ closed = null, votersCount = null, ...rest }) {
676
+ const note = noteDoc(rest);
677
+ const choices = options.map(o => ({
678
+ type: 'Note',
679
+ name: String(o.title),
680
+ replies: { type: 'Collection', totalItems: Number(o.votes) || 0 },
681
+ }));
682
+ const question = {
683
+ ...note,
684
+ type: 'Question',
685
+ [multiple ? 'anyOf' : 'oneOf']: choices,
686
+ };
687
+ if (endTime) question.endTime = endTime;
688
+ if (closed) question.closed = closed;
689
+ if (votersCount !== null && votersCount !== undefined) {
690
+ question['@context'] = [AS_CTX, {
691
+ toot: 'http://joinmastodon.org/ns#',
692
+ votersCount: {
693
+ '@id': 'toot:votersCount',
694
+ '@type': 'http://www.w3.org/2001/XMLSchema#nonNegativeInteger',
695
+ },
696
+ }];
697
+ question.votersCount = votersCount;
698
+ }
699
+ return question;
700
+ }
701
+
648
702
  // The id is a document of its own, not `note.id + '#create'`. A group wraps this
649
703
  // whole activity in its Announce (FEP-1b12), and the receiver resolves it by
650
704
  // dereferencing this id — a fragment would just serve the Note back under a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod-server",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "The FediPod Server: a full ActivityPub server as a Community Solid Server component.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
package/run-agent.mjs CHANGED
@@ -324,6 +324,7 @@ export class Agent {
324
324
  this.deliverer?.stop();
325
325
  this.tagfeed?.stop();
326
326
  clearInterval(this.schedTimer);
327
+ this.publisher?.stopPolls();
327
328
  this.deliverer = new Deliverer({
328
329
  store: this.store, rsaPrivate: keys.rsaPrivate, keyId: this.urls.actor + '#main-key',
329
330
  actorId: this.urls.actor, edPrivate: keys.edPrivate,
@@ -338,6 +339,10 @@ export class Agent {
338
339
  // Whether the fediverse tree is on the pod at all, so the ACL check does
339
340
  // not probe for something the default layout keeps on local disk.
340
341
  privateOnPod: !cred.privateRoot,
342
+ // Inside a pod server the client surface answers on the pod's own
343
+ // origin, so it can be advertised. Standalone it is on loopback, and
344
+ // naming it in a world-readable actor would send clients nowhere.
345
+ clientOrigin: this.embedded ? this.urls.base : null,
341
346
  });
342
347
  // Intake is constructed even for viewers — its signed fetchAP powers
343
348
  // search/deref; start() (draining) is active-only.
@@ -543,6 +548,10 @@ export class Agent {
543
548
  }).then(() => this.log(`scheduled post published (${e.id})`))
544
549
  .catch(err => this.log(`scheduled post ${e.id} failed: ${err.message} — dropped`));
545
550
  }
551
+ // A poll whose time is up is shut on the same sweep: it stops taking
552
+ // answers here, and everyone holding it is told once.
553
+ this.publisher.closeDuePolls()
554
+ .catch(err => this.log(`closing polls: ${err.message}`));
546
555
  }, 30_000);
547
556
  this.schedTimer.unref();
548
557
  // A CSV import interrupted by a restart or a handoff picks back up here.
@@ -647,6 +656,7 @@ export class Agent {
647
656
  this.deliverer?.stop();
648
657
  this.importer?.stop();
649
658
  clearInterval(this.schedTimer);
659
+ this.publisher?.stopPolls();
650
660
  // The lease too: standing down means standing down. Left renewing, a viewer
651
661
  // keeps writing to the pod on the active agent's behalf and can win the
652
662
  // lease back on a conditional PUT it had no business making.