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/README.md CHANGED
@@ -124,6 +124,15 @@ with the password. The authorization code is bound to the client's registered
124
124
  redirect, and only that client, holding its secret, can exchange it for a
125
125
  token.
126
126
 
127
+ An ActivityPub client needs none of that arranged by hand. The actor says
128
+ where to sign in, and `/.well-known/oauth-authorization-server` says the same
129
+ thing at the address such clients look for first. An app that keeps no secret,
130
+ which is anything running in a browser, signs in by answering a challenge it
131
+ set at the start. An app that publishes a document about itself is known by
132
+ that document's address and registers nothing here. Once in, it posts to the
133
+ outbox the actor names, and reads what the identity received at `/ap/inbox`,
134
+ which nobody but the owner may read.
135
+
127
136
  A client API is rooted at an origin, so each identity needs an origin of its
128
137
  own: subdomain pods, one per identity. A second identity on a host is
129
138
  refused at opt-in rather than half-working.
@@ -147,6 +156,7 @@ curl -X POST https://mei.example.org/fedipod/config \
147
156
  **Some pod paths stop being served.** On an identity's origin the paths above
148
157
  belong to the identity, so pod resources at those names — a container called
149
158
  `api`, `oauth` or `fedipod`, or documents at `ap/actor`, `ap/outbox`,
159
+ `ap/inbox`, `.well-known/oauth-authorization-server`,
150
160
  `.well-known/nodeinfo` and `nodeinfo/2.0` — are not served over HTTP there.
151
161
  They stay in the pod and in its listings. Every other path is the pod, exactly
152
162
  as before.
package/dist/claims.js CHANGED
@@ -23,7 +23,17 @@ function claims(input, frontHost) {
23
23
  // What an identity answers on its own pod's origin: the protocol routes other
24
24
  // software addresses it by, and the one path its owner's pages live under.
25
25
  // Everything else on that origin is the pod, and falls through to CSS.
26
- const AGENT_PATHS = new Set(['/ap/actor', '/ap/outbox', '/.well-known/nodeinfo', '/nodeinfo/2.0']);
26
+ const AGENT_PATHS = new Set([
27
+ '/ap/actor', '/ap/outbox',
28
+ // The owner reading their own mail. Deliveries still go to the inbox
29
+ // container on the pod, which the actor document names; nothing about
30
+ // receiving changes, and nothing at this address was ever served before.
31
+ '/ap/inbox',
32
+ '/.well-known/nodeinfo', '/nodeinfo/2.0',
33
+ // Where a client looks first to learn how to sign in. Nothing was served
34
+ // at this name before, so no pod resource is displaced.
35
+ '/.well-known/oauth-authorization-server',
36
+ ]);
27
37
  const AGENT_PREFIXES = ['/api/', '/oauth/'];
28
38
  /**
29
39
  * True when this request belongs to an identity's client surface.
package/lib/admin.mjs CHANGED
@@ -485,9 +485,19 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
485
485
  // C2S (ActivityPub §6) carries its own authentication — a Solid-OIDC
486
486
  // DPoP proof or the facade's bearer — so the dk-token gate does not
487
487
  // stand in front of it. The Host/Origin firewall above still does.
488
- if (p === '/ap/outbox' || p === '/ap/actor') {
488
+ if (p === '/ap/outbox' || p === '/ap/actor' || p === '/ap/inbox') {
489
489
  if (await c2s.handle(req, res, p, url)) return;
490
490
  }
491
+ // Where a client looks first to find out how to sign in (RFC 8414), and
492
+ // in front of the door for the same reason C2S is: a client that has to
493
+ // be handed a secret before it can ask how to sign in cannot set itself
494
+ // up at all. It names endpoints and nothing else, the endpoints it names
495
+ // refuse without a password anyway, and the host and origin firewall
496
+ // above still decides who gets this far.
497
+ if (p === '/.well-known/oauth-authorization-server') {
498
+ const scheme = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
499
+ return json(res, 200, masto.authorizationServerMetadata(`${scheme}://${req.headers.host}`));
500
+ }
491
501
  if (atDoor && gate(req, res)) return;
492
502
  if (p === '/api/v1/streaming/health') {
493
503
  res.writeHead(200, { 'content-type': 'text/plain' }); res.end('OK'); return;
package/lib/c2s.mjs CHANGED
@@ -5,14 +5,26 @@
5
5
  // SAME helper the facade and admin surfaces use — this module re-implements
6
6
  // no persistence and no delivery, so one write path stays one.
7
7
  //
8
- // GETs are redirects: the pod's documents are the canonical actor and outbox,
9
- // and a second renderer here would only drift from them.
8
+ // GETs on the actor and outbox are redirects: the pod's documents are the
9
+ // canonical ones, and a second renderer here would only drift from them.
10
+ //
11
+ // The inbox is the exception, and has to be. Deliveries land in a container on
12
+ // the pod which the drain empties as it handles each item, so reading that
13
+ // container tells the owner only what has not been dealt with yet. What was
14
+ // actually received is whole only in the archive, so §5.2's "the owner can
15
+ // read their own inbox" is served from there, by this agent, to the owner
16
+ // alone.
10
17
 
11
18
  import * as social from './social.mjs';
12
19
  import * as wire from './wire.mjs';
13
20
 
14
21
  const MAX_BODY = 512 * 1024; // same ceiling the inbox drain enforces
15
22
 
23
+ // How many archived items one page of the inbox will read. A page is one
24
+ // month, and a month with more than this is served short rather than costing
25
+ // the pod an unbounded read; the log says when that happened.
26
+ const MAX_INBOX_PAGE = 500;
27
+
16
28
  // §6 names activities; anything else with a type is an object to wrap.
17
29
  const ACTIVITY_TYPES = new Set([
18
30
  'Create', 'Update', 'Delete', 'Follow', 'Like', 'Announce', 'Undo',
@@ -60,14 +72,102 @@ export class C2S {
60
72
  return iri ? this.store.getStatuses().find((s) => s.noteId === iri) : null;
61
73
  }
62
74
 
75
+ /** The months the archive holds, newest first. One container listing. */
76
+ async archiveMonths() {
77
+ const archive = this.agent.intake?.archive;
78
+ if (!archive) return [];
79
+ const { names } = await archive.list('');
80
+ return (names || [])
81
+ .map((n) => n.replace(/\/$/u, ''))
82
+ .filter((n) => /^\d{4}-\d{2}$/u.test(n))
83
+ .sort()
84
+ .reverse();
85
+ }
86
+
87
+ /**
88
+ * The owner's own inbox, §5.2. Paged by month because that is how the
89
+ * archive is stored, so a page costs one listing and a read per item and no
90
+ * page is dearer for another month being large.
91
+ */
92
+ async sendInbox(res, url) {
93
+ const id = `${this.urls.base}ap/inbox`;
94
+ const page = url?.searchParams?.get('page') || null;
95
+ let months;
96
+ try {
97
+ months = await this.archiveMonths();
98
+ } catch (e) {
99
+ return this.send(res, 502, { error: `the archive could not be read: ${e.message}` });
100
+ }
101
+
102
+ if (!page) {
103
+ if (!months.length && this.store.getConfig()?.archiveInbox === false) {
104
+ this.log('inbox read: nothing to show — this identity does not keep what it receives');
105
+ }
106
+ return this.send(res, 200, {
107
+ '@context': wire.AS_CTX, id, type: 'OrderedCollection',
108
+ ...(months.length ? { first: `${id}?page=${months[0]}` } : { orderedItems: [] }),
109
+ });
110
+ }
111
+ if (!/^\d{4}-\d{2}$/u.test(page)) {
112
+ return this.send(res, 400, { error: 'page names a month, written 2026-09' });
113
+ }
114
+
115
+ const archive = this.agent.intake?.archive;
116
+ let names = [];
117
+ try {
118
+ // The trailing slash matters: without it this names a document, not the
119
+ // container, and a pod answers about the wrong thing.
120
+ ({ names } = await archive.list(`${page}/`));
121
+ } catch (e) {
122
+ return this.send(res, 502, { error: `the archive could not be read: ${e.message}` });
123
+ }
124
+ const files = (names || []).filter((n) => n.endsWith('.json')).sort();
125
+ if (files.length > MAX_INBOX_PAGE) {
126
+ this.log(`inbox read: ${page} holds ${files.length} items; serving the first ${MAX_INBOX_PAGE}`);
127
+ }
128
+ const kept = [];
129
+ for (const file of files.slice(0, MAX_INBOX_PAGE)) {
130
+ // Read as written: these records are JSON-LD, and the default read asks
131
+ // turtle-first, which a server is free to answer with turtle.
132
+ const read = await archive.read(`${page}/${file}`, { accept: '*/*' });
133
+ if (!read?.ok || !read.body) continue;
134
+ try {
135
+ const record = JSON.parse(read.body);
136
+ // The record wraps the bytes as they arrived; the activity is those
137
+ // bytes, not a retelling of them.
138
+ kept.push({ at: record.receivedAt || '', activity: JSON.parse(record.raw) });
139
+ } catch { /* a record that will not parse is not one that can be served */ }
140
+ }
141
+ kept.sort((a, b) => String(b.at).localeCompare(String(a.at)));
142
+ const older = months.filter((m) => m < page)[0] || null;
143
+ return this.send(res, 200, {
144
+ '@context': wire.AS_CTX,
145
+ id: `${id}?page=${page}`,
146
+ type: 'OrderedCollectionPage',
147
+ partOf: id,
148
+ ...(older ? { next: `${id}?page=${older}` } : {}),
149
+ orderedItems: kept.map((k) => k.activity),
150
+ });
151
+ }
152
+
63
153
  async handle(req, res, pathname, url) { // eslint-disable-line no-unused-vars
64
- if (pathname !== '/ap/outbox' && pathname !== '/ap/actor') return false;
154
+ if (pathname !== '/ap/outbox' && pathname !== '/ap/actor' && pathname !== '/ap/inbox') return false;
65
155
  if (req.method === 'OPTIONS') {
66
- res.writeHead(204, { allow: 'GET, POST, OPTIONS' }); res.end(); return true;
156
+ res.writeHead(204, { allow: pathname === '/ap/inbox' ? 'GET, OPTIONS' : 'GET, POST, OPTIONS' });
157
+ res.end(); return true;
67
158
  }
68
159
  if (!this.agent.configured() || !this.urls) {
69
160
  return this.send(res, 409, { error: 'agent not configured' });
70
161
  }
162
+ if (pathname === '/ap/inbox') {
163
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
164
+ return this.send(res, 405, { error: "deliveries go to this actor's inbox on the pod, which the "
165
+ + 'actor document names; this address is the owner reading their own' });
166
+ }
167
+ const reader = await this.auth(req, pathname);
168
+ if (!reader.ok) return this.send(res, reader.status, { error: reader.error });
169
+ return this.sendInbox(res, url);
170
+ }
71
171
  if (req.method === 'GET' || req.method === 'HEAD') {
72
172
  // The pod's copy is the document; send the reader there.
73
173
  const target = pathname === '/ap/actor' ? this.urls.actor : this.urls.outbox;
@@ -134,8 +234,9 @@ export class C2S {
134
234
 
135
235
  switch (activity.type) {
136
236
  case 'Create': {
137
- if (!object || (object.type && object.type !== 'Note')) {
138
- return this.send(res, 422, { error: 'only a Note (or a bare Note) can be created here' });
237
+ const makes = object?.type || 'Note';
238
+ if (!object || (makes !== 'Note' && makes !== 'Question')) {
239
+ return this.send(res, 422, { error: 'only a Note or a Question (or a bare Note) can be created here' });
139
240
  }
140
241
  const visibility = this.visibilityOf(activity, object);
141
242
  if (!visibility) {
@@ -146,6 +247,30 @@ export class C2S {
146
247
  // visible characters rather than as markup. Documented v1 limit.
147
248
  const text = String(object.source?.content ?? object.content ?? '');
148
249
  if (!text.trim()) return this.send(res, 422, { error: 'the note has no content' });
250
+
251
+ // A Question is a poll: the choices are in oneOf (pick one) or anyOf
252
+ // (pick several), each naming itself, and endTime is when it shuts.
253
+ if (makes === 'Question') {
254
+ const one = arr(object.oneOf);
255
+ const many = arr(object.anyOf);
256
+ const titles = (one.length ? one : many).map((c) => String(c?.name ?? '').trim()).filter(Boolean);
257
+ try {
258
+ const question = await agent.publisher.publishQuestion(text, {
259
+ options: titles,
260
+ multiple: !one.length && many.length > 0,
261
+ expiresAt: object.endTime || null,
262
+ inReplyTo: idOf(object.inReplyTo) || undefined,
263
+ visibility,
264
+ spoilerText: object.summary || null,
265
+ });
266
+ return this.send(res, 201,
267
+ { id: wire.createActivityId(question.id), object: question.id },
268
+ { location: wire.createActivityId(question.id) });
269
+ } catch (e) {
270
+ return this.send(res, 422, { error: e.message });
271
+ }
272
+ }
273
+
149
274
  const attachments = arr(object.attachment).map((a) => ({
150
275
  url: a?.url, mediaType: a?.mediaType,
151
276
  ...(a?.name ? { description: a.name } : {}),
package/lib/embed.mjs CHANGED
@@ -202,6 +202,7 @@ export async function startEmbeddedAgent({
202
202
  agent.importer?.stop();
203
203
  clearInterval(agent.schedTimer);
204
204
  clearInterval(agent.refreshTimer);
205
+ agent.publisher?.stopPolls();
205
206
  // Same order the standalone agent's shutdown uses: write what is pending,
206
207
  // then let go of the lease so the next agent need not wait out the TTL.
207
208
  await Promise.allSettled([
package/lib/intake.mjs CHANGED
@@ -19,6 +19,7 @@ import { USER_AGENT } from './ua.mjs';
19
19
  import { PUBLIC } from './wire.mjs';
20
20
  import { HTTP_TIMEOUT_MS, readCapped } from './safefetch.mjs';
21
21
  import { linkTargets, REL } from './links.mjs';
22
+ import * as polls from './polls.mjs';
22
23
  import { dropFollower } from './store.mjs';
23
24
 
24
25
  const RDF = $rdf.Namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#');
@@ -349,13 +350,18 @@ export class Intake {
349
350
  || g.each(null, NOTIFY('channelType'), NOTIFY('WebSocketChannel2023'), null)
350
351
  .map(n => n.value).find(Boolean);
351
352
  if (!channel) { this.wsState = 'unavailable'; this.log('no WebSocketChannel2023 service — polling only'); return; }
353
+ // The topic is a POD resource, and it travels in the BODY — so the url map
354
+ // RemotePod applies to the request line never reaches it. A fronted
355
+ // identity's inbox url names the front, which the pod cannot grant read on,
356
+ // and the subscription came back 403. A no-op when unfronted.
357
+ const topic = this.urls.toPod ? this.urls.toPod(this.urls.inbox) : this.urls.inbox;
352
358
  const sub = await this.remote.fetch(channel, {
353
359
  method: 'POST',
354
360
  headers: { 'content-type': 'application/ld+json' },
355
361
  body: JSON.stringify({
356
362
  '@context': ['https://www.w3.org/ns/solid/notification/v1'],
357
363
  type: 'http://www.w3.org/ns/solid/notifications#WebSocketChannel2023',
358
- topic: this.urls.inbox,
364
+ topic,
359
365
  }),
360
366
  });
361
367
  const body = await readCapped(sub).then(JSON.parse).catch(() => null);
@@ -1426,6 +1432,22 @@ export class Intake {
1426
1432
  // somebody else's boost, or through a hashtag feed.
1427
1433
  if (this.store.isBlocked(author)) return `blocked author (${author})`;
1428
1434
 
1435
+ // An answer to one of our polls is a number on a document, not a post. It
1436
+ // arrives as an ordinary reply naming an option and carrying nothing else,
1437
+ // so filing it as one would put a blank entry in the thread and ring the
1438
+ // owner once per voter. Counted or refused — a second answer, an option we
1439
+ // do not offer, a poll already shut — it stops here either way.
1440
+ const asked = note.inReplyTo && this.store.getStatuses()
1441
+ .find(x => x.noteId === String(note.inReplyTo) && x.kind === 'post' && x.poll);
1442
+ if (asked && polls.isVoteShape(note)) {
1443
+ const counted = await this.publisher.recordVote(asked.noteId, author, note.name)
1444
+ .catch(e => { this.log(`vote on ${asked.noteId}: ${e.message}`); return false; });
1445
+ this.log(counted
1446
+ ? `vote counted (${note.name}): ${asked.noteId}`
1447
+ : `vote not counted (${note.name}) from ${author}: ${asked.noteId}`);
1448
+ return;
1449
+ }
1450
+
1429
1451
  // Anyone can Append to a public inbox, so arriving is not the same as
1430
1452
  // belonging in the home timeline. Follow Mastodon's split: people you
1431
1453
  // follow (and their boosts) are HOME; anyone else is a MENTION — kept,