fedipod 1.7.0 → 1.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.
Files changed (53) hide show
  1. package/README.md +14 -3
  2. package/gateway.md +16 -0
  3. package/groups.md +1 -0
  4. package/lib/client/c2s.mjs +112 -64
  5. package/lib/client/masto/index.mjs +9 -1
  6. package/lib/client/masto/render.mjs +5 -3
  7. package/lib/client/masto/statuses.mjs +7 -4
  8. package/lib/client/oidc-auth.mjs +10 -3
  9. package/lib/core/as2.mjs +1 -1
  10. package/lib/core/contexts/anno.json +126 -0
  11. package/lib/core/contexts/index.mjs +4 -0
  12. package/lib/core/contexts/map.json +2 -1
  13. package/lib/core/deliver.mjs +24 -1
  14. package/lib/core/intake/activities.mjs +54 -0
  15. package/lib/core/intake/index.mjs +34 -5
  16. package/lib/core/intake/notes.mjs +7 -0
  17. package/lib/core/publisher/collections.mjs +7 -4
  18. package/lib/core/publisher/index.mjs +49 -27
  19. package/lib/core/publisher/notes.mjs +154 -7
  20. package/lib/core/publisher/questions.mjs +11 -7
  21. package/lib/core/publisher/restore.mjs +27 -2
  22. package/lib/core/social.mjs +1 -0
  23. package/lib/core/store.mjs +13 -0
  24. package/lib/core/wire.mjs +71 -20
  25. package/lib/device/admin/static.mjs +2 -2
  26. package/lib/device/admin/surface.mjs +11 -2
  27. package/lib/gateway/front-core.mjs +76 -6
  28. package/lib/gateway/gateway-core.mjs +40 -0
  29. package/lib/gateway/httpsig.mjs +7 -3
  30. package/lib/pod/actor.mjs +2 -2
  31. package/lib/pod/transport.mjs +9 -3
  32. package/package.json +1 -1
  33. package/run-agent.mjs +11 -0
  34. package/scripts/refresh-contexts.mjs +5 -0
  35. package/scripts/stage-site.mjs +1 -0
  36. package/web/admin/index.html +2 -0
  37. package/web/admin/upkeep.js +9 -1
  38. package/web/app/README.md +1 -1
  39. package/web/app/agent.mjs +12 -0
  40. package/web/app/boot.mjs +49 -10
  41. package/web/app/dist/boot.js +47 -7
  42. package/web/app/dist/boot.js.map +2 -2
  43. package/web/app/dist/sw.js +1604 -722
  44. package/web/app/dist/sw.js.map +4 -4
  45. package/web/app/index.html +16 -6
  46. package/web/app/site/_redirects +1 -0
  47. package/web/app/site/admin/index.html +2 -0
  48. package/web/app/site/admin/upkeep.js +9 -1
  49. package/web/app/site/boot.js +89 -37
  50. package/web/app/site/index.html +16 -6
  51. package/web/app/site/sw.js +1605 -723
  52. package/web/front/#new-account.html# +0 -43
  53. package/web/front/new-account.html~ +0 -50
package/README.md CHANGED
@@ -9,15 +9,14 @@ one timeline. Your posts, followers and settings stay on your pod.
9
9
  The easiest way to run FediPod is to use it in any browser at https://fedipod.net. Nothing to install. A wizard will walk you through getting a pod (you can also bring your own) and creating a Fediverse identity attached to the pod.
10
10
 
11
11
  There are also a number of [other ways to run FediPod](#other-ways-to-run-fedipod) which offer a variety of scenarios. If interested in the code, see also : [architecture overview](architecture.md) and [files overview](files-overview.md).
12
+ Which specs FediPod follows, and where it stops short: [specs-in-use.md](specs-in-use.md).
12
13
 
13
14
  ## Requirements
14
15
 
15
16
  - A current browser, on a desktop or a phone.
16
17
  - A Solid pod with a host name of its own, such as
17
18
  `https://alice.solidcommunity.net/`. Sign-up can create one for you at
18
- solidcommunity.net or another provider, or use a pod you already have. A
19
- pod that lives on a suffix-based host, like `https://server.example/alice/`,
20
- cannot be a Fediverse address.
19
+ solidcommunity.net or another provider, or use a pod you already have.
21
20
  A pod on a suffix-based host, like `https://server.example/alice/`,
22
21
  works too. Its address is then `@handle@fedipod.net`, because the shared
23
22
  host cannot answer for the handle; your posts, key and data stay on your pod.
@@ -38,9 +37,16 @@ There are also a number of [other ways to run FediPod](#other-ways-to-run-fedipo
38
37
  4. Enter your pod password once. It creates the account and locks your
39
38
  signing key. The password is not stored.
40
39
 
40
+ <!-- CLAUDE 2026-09-15 — the short profile address; delete these markers when done -->
41
+ Anyone can open your profile at `https://fedipod.net/@handle`, signed in or
42
+ not.
43
+ <!-- /CLAUDE -->
44
+
41
45
  Your account then opens in the client. To use it from another browser, go to
42
46
  https://fedipod.net, enter your address, sign in at your pod, and unlock your
43
47
  key with your password once on that browser.
48
+ If you have changed your pod password since you signed up, choose "make a new
49
+ signing key" on that screen and use the password you use now.
44
50
 
45
51
  ## What you can do
46
52
 
@@ -70,6 +76,11 @@ hands back stays in your browser unless you choose to keep it on your pod.
70
76
  domain blocks import from the manage page. Your old account can be listed as
71
77
  an alias, so a Move from it lands here.
72
78
 
79
+ **Posting from another app.** Any app that speaks ActivityPub
80
+ client-to-server, dokieli for one, can post as you. It sends to the outbox
81
+ address in your actor document, which your WebID profile also names, signed in
82
+ at your pod. The post goes out the next time you open fedipod.net.
83
+
73
84
  **The manage page.** `manage account` in the bar opens it: your profile,
74
85
  aliases, the gateway, key rotation, recovering posts, parking, moving to
75
86
  another server, retiring, and clearing a backlog. It is the same interface
package/gateway.md CHANGED
@@ -10,6 +10,9 @@ Your name, your signing key and your data stay on your own pod. The gateway is
10
10
  **keyless** — it never holds the key you sign with, so it cannot post as you,
11
11
  read your private things, or be you anywhere. The worst a broken one can do is
12
12
  push items into your inbox, and those still face your agent's own checks.
13
+ With the outbox door below, a gateway can also hand your agent a post marked as
14
+ yours, which your agent then signs and sends. It still holds no key, but what you
15
+ trust it with grows by that much.
13
16
 
14
17
  A FediPod install works without any gateway at all. Deliveries go straight to
15
18
  your pod inbox, which holds them whether your agent is running or not.
@@ -102,6 +105,19 @@ yourself. When you attach to a multi-user gateway, the direction is reversed:
102
105
  the gateway mints the secret and answers the attach with it, and your agent
103
106
  records it.
104
107
 
108
+ ## The outbox door
109
+
110
+ The gateway also takes your own posts from any app that speaks ActivityPub
111
+ client-to-server, dokieli for one. Your actor document names the door as your
112
+ outbox, and your WebID profile names it as `as:outbox`. The app sends the post
113
+ there, signed in at your pod. The door checks that the token is yours, puts the
114
+ post in your inbox marked as yours, and answers with the address the post will
115
+ have. Your agent publishes it and sends it to your followers the next time it
116
+ runs: for a browser account, the next time you open the site.
117
+
118
+ A post with no audience of its own goes out as a public post. A post that is
119
+ not a note, an annotation say, is kept as the app sent it, under your name.
120
+
105
121
  ## What the gateway can see
106
122
 
107
123
  It reads only public data to decide what concerns you: your published
package/groups.md CHANGED
@@ -55,6 +55,7 @@ queue is its own list, separate from post review, and appears on the group's
55
55
  admin page as **Moderation requests** when something is waiting: carry it out,
56
56
  or turn it down.
57
57
  A followed group's own announced deletion of a post it carried to you is honoured.
58
+ A group that withdraws a post it carried to you takes it off your timeline too.
58
59
 
59
60
  ## Inviting people
60
61
 
@@ -8,6 +8,11 @@
8
8
  // GETs on the actor and outbox are redirects: the pod's documents are the
9
9
  // canonical ones, and a second renderer here would only drift from them.
10
10
  //
11
+ // `dispatch` takes an activity and answers with { status, body, headers }; it
12
+ // touches no request or response. The HTTP handler below is one caller. The
13
+ // other is the inbox drain, handing over an activity the Gateway took at the
14
+ // outbox door on the owner's behalf and stamped as theirs.
15
+ //
11
16
  // The inbox is the exception, and has to be. Deliveries land in a container on
12
17
  // the pod which the drain empties as it handles each item, so reading that
13
18
  // container tells the owner only what has not been dealt with yet. What was
@@ -18,6 +23,7 @@
18
23
  import * as social from '../core/social.mjs';
19
24
  import * as wire from '../core/wire.mjs';
20
25
  import { readLenient } from '../core/as2.mjs';
26
+ import { safeSlug } from '../core/publisher/notes.mjs';
21
27
 
22
28
  const MAX_BODY = 512 * 1024; // same ceiling the inbox drain enforces
23
29
 
@@ -154,7 +160,10 @@ export class C2S {
154
160
  async handle(req, res, pathname, url) { // eslint-disable-line no-unused-vars
155
161
  if (pathname !== '/ap/outbox' && pathname !== '/ap/actor' && pathname !== '/ap/inbox') return false;
156
162
  if (req.method === 'OPTIONS') {
157
- res.writeHead(204, { allow: pathname === '/ap/inbox' ? 'GET, OPTIONS' : 'GET, POST, OPTIONS' });
163
+ // Accept-Post is what a client such as dokieli reads to choose a format;
164
+ // naming JSON only is what makes it send JSON-LD rather than HTML.
165
+ res.writeHead(204, pathname === '/ap/inbox' ? { allow: 'GET, OPTIONS' }
166
+ : { allow: 'GET, POST, OPTIONS', 'accept-post': 'application/ld+json, application/activity+json' });
158
167
  res.end(); return true;
159
168
  }
160
169
  if (!this.agent.configured() || !this.urls) {
@@ -166,7 +175,7 @@ export class C2S {
166
175
  + 'actor document names; this address is the owner reading their own' });
167
176
  }
168
177
  const reader = await this.auth(req, pathname);
169
- if (!reader.ok) return this.send(res, reader.status, { error: reader.error });
178
+ if (!reader.ok) return this.send(res, reader.status, { error: reader.error }, reader.headers || {});
170
179
  return this.sendInbox(res, url);
171
180
  }
172
181
  if (req.method === 'GET' || req.method === 'HEAD') {
@@ -181,7 +190,7 @@ export class C2S {
181
190
  }
182
191
 
183
192
  const who = await this.auth(req, pathname);
184
- if (!who.ok) return this.send(res, who.status, { error: who.error });
193
+ if (!who.ok) return this.send(res, who.status, { error: who.error }, who.headers || {});
185
194
 
186
195
  // A viewer (another agent holds the lease) may not act — but the user
187
196
  // acting HERE outranks an idle active agent elsewhere, exactly as on the
@@ -196,9 +205,16 @@ export class C2S {
196
205
  // takes decisions from it and the publisher builds the document that is
197
206
  // actually posted, so nothing a client sent is republished verbatim and a
198
207
  // term it aliased still means what it says.
199
- let activity;
208
+ // `raw` is the document as the client wrote it. The graph is what
209
+ // decisions are taken from; the bytes are what an object that is stored
210
+ // as sent (publishObject) is stored from — a read against our contexts
211
+ // rewrites a stranger's terms to full IRIs, which is right for reading and
212
+ // wrong for keeping.
213
+ let activity; let raw = null;
200
214
  try {
201
- const read = await readLenient(await readBody(req));
215
+ const body = await readBody(req);
216
+ try { raw = JSON.parse(body); } catch { raw = null; }
217
+ const read = await readLenient(body);
202
218
  activity = read.view ?? read.doc;
203
219
  } catch (e) {
204
220
  return this.send(res, 400, { error: `unreadable body: ${e.message}` });
@@ -206,34 +222,57 @@ export class C2S {
206
222
  if (!activity || typeof activity !== 'object' || Array.isArray(activity) || !activity.type) {
207
223
  return this.send(res, 400, { error: 'a typed ActivityStreams object is required' });
208
224
  }
209
- // A bare object arrives without an activity around it; the server supplies
210
- // the Create (§6.2.1), carrying the object's own addressing up onto it.
211
- if (!ACTIVITY_TYPES.has(activity.type)) {
212
- activity = { type: 'Create', object: activity, to: activity.to, cc: activity.cc };
213
- }
214
-
215
- try {
216
- return await this.dispatch(res, activity);
217
- } catch (e) {
218
- this.log(`c2s ${activity.type}: ${e.message}`);
219
- return this.send(res, 422, { error: e.message || String(e) });
220
- }
225
+ // The name the client asks for its new document (LDP's Slug), taken when
226
+ // it is plain and free — it is what lets the client know the address of
227
+ // what it made before anything answers.
228
+ const slug = safeSlug(req.headers.slug) || null;
229
+ const r = await this.dispatch(activity, { slug, raw });
230
+ return this.send(res, r.status, r.body, r.headers);
221
231
  }
222
232
 
223
233
  // Addressing → the facade's four visibilities, inverting the table the
224
- // composer writes (wire.noteDoc). Addressing is required: a post whose
225
- // audience the client never stated is not guessed at in either direction.
234
+ // composer writes (wire.addressing). Nothing stated is a public post — what
235
+ // every client means by a post with no audience chosen, and what a client
236
+ // that never addresses (dokieli's annotations) needs.
226
237
  visibilityOf(activity, object) {
227
238
  const to = arr(activity.to ?? object?.to).map(idOf);
228
239
  const cc = arr(activity.cc ?? object?.cc).map(idOf);
229
- if (!to.length && !cc.length) return null;
240
+ if (!to.length && !cc.length) return 'public';
230
241
  if (to.includes(wire.PUBLIC)) return 'public';
231
242
  if (cc.includes(wire.PUBLIC)) return 'unlisted';
232
243
  if (to.includes(this.urls.followers)) return 'private';
233
244
  return 'direct';
234
245
  }
235
246
 
236
- async dispatch(res, activity) {
247
+ async dispatch(activity, { slug = null, raw = null } = {}) {
248
+ const reply = (status, body, headers = {}) => ({ status, body, headers });
249
+ if (!activity || typeof activity !== 'object' || Array.isArray(activity) || !activity.type) {
250
+ return reply(400, { error: 'a typed ActivityStreams object is required' });
251
+ }
252
+ // A bare object arrives without an activity around it; the server supplies
253
+ // the Create (§6.2.1), carrying the object's own addressing up onto it.
254
+ if (!ACTIVITY_TYPES.has(activity.type)) {
255
+ activity = { type: 'Create', object: activity, to: activity.to, cc: activity.cc };
256
+ }
257
+ try {
258
+ return await this._dispatch(activity, { slug, raw, reply });
259
+ } catch (e) {
260
+ this.log(`c2s ${activity?.type}: ${e.message}`);
261
+ return reply(422, { error: e.message || String(e) });
262
+ }
263
+ }
264
+
265
+ // The actors a client addressed by id, beyond the audience words: the ones
266
+ // in to/cc are listed and delivered to; the ones in bto/bcc are delivered
267
+ // to and never listed (§6).
268
+ addressedActors(activity, object) {
269
+ const pick = (...fields) => [...new Set(fields.flatMap((f) => arr(activity[f] ?? object?.[f]).map(idOf)))]
270
+ .filter((a) => typeof a === 'string' && /^https?:\/\//u.test(a)
271
+ && a !== wire.PUBLIC && a !== this.urls.followers && a !== this.urls.actor);
272
+ return { also: pick('to', 'cc'), deliverTo: pick('bto', 'bcc') };
273
+ }
274
+
275
+ async _dispatch(activity, { slug, raw, reply }) {
237
276
  const agent = this.agent;
238
277
  const object = typeof activity.object === 'object' && activity.object !== null
239
278
  ? activity.object : null;
@@ -242,18 +281,23 @@ export class C2S {
242
281
  switch (activity.type) {
243
282
  case 'Create': {
244
283
  const makes = object?.type || 'Note';
245
- if (!object || (makes !== 'Note' && makes !== 'Question')) {
246
- return this.send(res, 422, { error: 'only a Note or a Question (or a bare Note) can be created here' });
247
- }
284
+ if (!object) return reply(422, { error: 'a Create carries the object it creates' });
248
285
  const visibility = this.visibilityOf(activity, object);
249
- if (!visibility) {
250
- return this.send(res, 400, { error: 'state the audience: to/cc must address someone (as:Public, your followers collection, or actors)' });
286
+ const { also, deliverTo } = this.addressedActors(activity, object);
287
+ // Not a Note and not a poll: stored as sent, under this actor, and
288
+ // the Create around it delivered — a Web Annotation, for one.
289
+ if (makes !== 'Note' && makes !== 'Question') {
290
+ const asSent = raw && typeof raw === 'object' && !Array.isArray(raw)
291
+ ? (ACTIVITY_TYPES.has(raw.type) ? (raw.object && typeof raw.object === 'object' ? raw.object : null) : raw)
292
+ : null;
293
+ const made = await agent.publisher.publishObject(asSent || object, { visibility, slug, also, deliverTo });
294
+ return reply(201, { id: made.createId, object: made.id }, { location: made.createId });
251
295
  }
252
296
  // `source.content` is the client's plain text when it sends one; bare
253
297
  // `content` is TREATED as plain text and escaped — markup survives as
254
298
  // visible characters rather than as markup. Documented v1 limit.
255
299
  const text = String(object.source?.content ?? object.content ?? '');
256
- if (!text.trim()) return this.send(res, 422, { error: 'the note has no content' });
300
+ if (!text.trim()) return reply(422, { error: 'the note has no content' });
257
301
 
258
302
  // A Question is a poll: the choices are in oneOf (pick one) or anyOf
259
303
  // (pick several), each naming itself, and endTime is when it shuts.
@@ -269,12 +313,13 @@ export class C2S {
269
313
  inReplyTo: idOf(object.inReplyTo) || undefined,
270
314
  visibility,
271
315
  spoilerText: object.summary || null,
316
+ sensitive: object.sensitive === true,
272
317
  });
273
- return this.send(res, 201,
318
+ return reply(201,
274
319
  { id: wire.createActivityId(question.id), object: question.id },
275
320
  { location: wire.createActivityId(question.id) });
276
321
  } catch (e) {
277
- return this.send(res, 422, { error: e.message });
322
+ return reply(422, { error: e.message });
278
323
  }
279
324
  }
280
325
 
@@ -287,22 +332,24 @@ export class C2S {
287
332
  attachments,
288
333
  visibility,
289
334
  spoilerText: object.summary || null,
335
+ sensitive: object.sensitive === true,
336
+ slug, also, deliverTo,
290
337
  });
291
- return this.send(res, 201, { id: wire.createActivityId(note.id), object: note.id },
338
+ return reply(201, { id: wire.createActivityId(note.id), object: note.id },
292
339
  { location: wire.createActivityId(note.id) });
293
340
  }
294
341
 
295
342
  case 'Update': {
296
343
  if (objectId === this.urls.actor) {
297
- return this.send(res, 422, { error: 'edit the profile on the admin surface; actor updates are not taken here' });
344
+ return reply(422, { error: 'edit the profile on the admin surface; actor updates are not taken here' });
298
345
  }
299
346
  const s = this.byIri(objectId);
300
- if (!s) return this.send(res, 404, { error: 'no such note here' });
347
+ if (!s) return reply(404, { error: 'no such note here' });
301
348
  if (s.actor !== this.urls.actor || s.kind !== 'post') {
302
- return this.send(res, 403, { error: 'not your note' });
349
+ return reply(403, { error: 'not your note' });
303
350
  }
304
351
  const text = String(object?.source?.content ?? object?.content ?? '');
305
- if (!text.trim()) return this.send(res, 422, { error: 'the edit has no content' });
352
+ if (!text.trim()) return reply(422, { error: 'the edit has no content' });
306
353
  const attachments = object?.attachment !== undefined
307
354
  ? arr(object.attachment).map((a) => ({
308
355
  url: a?.url, mediaType: a?.mediaType,
@@ -310,54 +357,55 @@ export class C2S {
310
357
  })).filter((a) => a.url)
311
358
  : null;
312
359
  await agent.publisher.updateNote(s, {
313
- content: text, spoilerText: object?.summary || null, attachments,
360
+ content: text, spoilerText: object?.summary || null,
361
+ sensitive: object?.sensitive === undefined ? null : object.sensitive === true, attachments,
314
362
  });
315
- return this.send(res, 200, { ok: true, object: s.noteId });
363
+ return reply(200, { ok: true, object: s.noteId });
316
364
  }
317
365
 
318
366
  case 'Delete': {
319
367
  if (objectId === this.urls.actor) {
320
- return this.send(res, 422, { error: 'retiring the actor is done on the admin surface, where it asks twice' });
368
+ return reply(422, { error: 'retiring the actor is done on the admin surface, where it asks twice' });
321
369
  }
322
370
  const s = this.byIri(objectId);
323
- if (!s) return this.send(res, 404, { error: 'no such note here' });
371
+ if (!s) return reply(404, { error: 'no such note here' });
324
372
  if (s.actor !== this.urls.actor || s.kind !== 'post') {
325
- return this.send(res, 403, { error: 'not your note' });
373
+ return reply(403, { error: 'not your note' });
326
374
  }
327
375
  const r = await social.deleteNote(agent, s);
328
- if (!r.ok) return this.send(res, 502, { error: r.error, stillPublished: r.stillPublished });
329
- return this.send(res, 200, { ok: true });
376
+ if (!r.ok) return reply(502, { error: r.error, stillPublished: r.stillPublished });
377
+ return reply(200, { ok: true });
330
378
  }
331
379
 
332
380
  case 'Follow': {
333
- if (!objectId) return this.send(res, 400, { error: 'whom? object must name an actor' });
381
+ if (!objectId) return reply(400, { error: 'whom? object must name an actor' });
334
382
  // An acct: form or bare handle resolves through WebFinger; an https
335
383
  // IRI is fetched directly.
336
384
  if (/^acct:|^@|^[^/@]+@[^/@]+$/.test(objectId) && !/^https?:/.test(objectId)) {
337
385
  const r = await social.followHandle(agent, objectId.replace(/^acct:/, ''));
338
386
  const rec = this.store.getContacts().following.find((f) => f.actor === r.actor);
339
- return this.send(res, 201, { id: rec?.followActivity?.id, object: r.actor },
387
+ return reply(201, { id: rec?.followActivity?.id, object: r.actor },
340
388
  rec?.followActivity?.id ? { location: rec.followActivity.id } : {});
341
389
  }
342
390
  const doc = await social.followActor(agent, objectId);
343
391
  const rec = this.store.getContacts().following.find((f) => f.actor === doc.id);
344
- return this.send(res, 201, { id: rec?.followActivity?.id, object: doc.id },
392
+ return reply(201, { id: rec?.followActivity?.id, object: doc.id },
345
393
  rec?.followActivity?.id ? { location: rec.followActivity.id } : {});
346
394
  }
347
395
 
348
396
  case 'Like': {
349
397
  const s = this.byIri(objectId);
350
- if (!s) return this.send(res, 422, { error: 'that note is not held here — like what the timeline holds' });
398
+ if (!s) return reply(422, { error: 'that note is not held here — like what the timeline holds' });
351
399
  const updated = await social.favourite(agent, s);
352
- return this.send(res, 201, { id: updated.likeActivity?.id, object: s.noteId },
400
+ return reply(201, { id: updated.likeActivity?.id, object: s.noteId },
353
401
  updated.likeActivity?.id ? { location: updated.likeActivity.id } : {});
354
402
  }
355
403
 
356
404
  case 'Announce': {
357
405
  const s = this.byIri(objectId);
358
- if (!s) return this.send(res, 422, { error: 'that note is not held here — boost what the timeline holds' });
406
+ if (!s) return reply(422, { error: 'that note is not held here — boost what the timeline holds' });
359
407
  const updated = await social.reblog(agent, s);
360
- return this.send(res, 201, { id: updated.announceActivity?.id, object: s.noteId },
408
+ return reply(201, { id: updated.announceActivity?.id, object: s.noteId },
361
409
  updated.announceActivity?.id ? { location: updated.announceActivity.id } : {});
362
410
  }
363
411
 
@@ -368,37 +416,37 @@ export class C2S {
368
416
  const innerId = idOf(activity.object);
369
417
  if (inner?.type === 'Block') {
370
418
  const target = idOf(inner.object);
371
- if (!target) return this.send(res, 400, { error: 'unblock whom?' });
419
+ if (!target) return reply(400, { error: 'unblock whom?' });
372
420
  await social.unblockActor(agent, target);
373
- return this.send(res, 200, { ok: true, object: target });
421
+ return reply(200, { ok: true, object: target });
374
422
  }
375
423
  const statuses = this.store.getStatuses();
376
424
  let s = innerId ? statuses.find((x) => x.likeActivity?.id === innerId) : null;
377
425
  if (!s && inner?.type === 'Like') s = this.byIri(idOf(inner.object));
378
426
  if (s?.favourited) {
379
427
  const updated = await social.unfavourite(agent, s);
380
- return this.send(res, 200, { ok: true, object: updated.noteId });
428
+ return reply(200, { ok: true, object: updated.noteId });
381
429
  }
382
430
  s = innerId ? statuses.find((x) => x.announceActivity?.id === innerId) : null;
383
431
  if (!s && inner?.type === 'Announce') s = this.byIri(idOf(inner.object));
384
432
  if (s?.reblogged) {
385
433
  const updated = await social.unreblog(agent, s);
386
- return this.send(res, 200, { ok: true, object: updated.noteId });
434
+ return reply(200, { ok: true, object: updated.noteId });
387
435
  }
388
436
  const following = this.store.getContacts().following;
389
437
  const rec = following.find((f) => f.followActivity?.id === innerId)
390
438
  || (inner?.type === 'Follow' ? following.find((f) => f.actor === idOf(inner.object)) : null);
391
439
  if (rec) {
392
440
  await social.unfollowActor(agent, rec.actor);
393
- return this.send(res, 200, { ok: true, object: rec.actor });
441
+ return reply(200, { ok: true, object: rec.actor });
394
442
  }
395
- return this.send(res, 422, { error: 'nothing here matches what that Undo names' });
443
+ return reply(422, { error: 'nothing here matches what that Undo names' });
396
444
  }
397
445
 
398
446
  case 'Block': {
399
- if (!objectId) return this.send(res, 400, { error: 'block whom? object must name an actor' });
447
+ if (!objectId) return reply(400, { error: 'block whom? object must name an actor' });
400
448
  await social.blockActor(agent, objectId);
401
- return this.send(res, 200, { ok: true, object: objectId });
449
+ return reply(200, { ok: true, object: objectId });
402
450
  }
403
451
 
404
452
  case 'Add':
@@ -406,12 +454,12 @@ export class C2S {
406
454
  // The one collection a client may edit is the pins (§7.6/§7.9 in the
407
455
  // other direction): target must be the featured collection.
408
456
  if (idOf(activity.target) !== this.urls.featured) {
409
- return this.send(res, 422, { error: 'the featured collection is the one Add/Remove edits here' });
457
+ return reply(422, { error: 'the featured collection is the one Add/Remove edits here' });
410
458
  }
411
459
  const s = this.byIri(objectId);
412
- if (!s) return this.send(res, 404, { error: 'no such note here' });
460
+ if (!s) return reply(404, { error: 'no such note here' });
413
461
  const updated = await social.pinStatus(agent, s, activity.type === 'Add');
414
- return this.send(res, 200, { ok: true, object: updated.noteId, pinned: !!updated.pinned });
462
+ return reply(200, { ok: true, object: updated.noteId, pinned: !!updated.pinned });
415
463
  }
416
464
 
417
465
  case 'Accept':
@@ -419,19 +467,19 @@ export class C2S {
419
467
  // Answering a held follow request: the object is the Follow (or the
420
468
  // requester). Which request is meant comes from the Follow's actor.
421
469
  const requester = object?.actor ? idOf(object.actor) : objectId;
422
- if (!requester) return this.send(res, 400, { error: 'whose request? object must name the Follow or its actor' });
470
+ if (!requester) return reply(400, { error: 'whose request? object must name the Follow or its actor' });
423
471
  const r = activity.type === 'Accept'
424
472
  ? await social.admitRequest(agent, requester).catch((e) => ({ error: e.message }))
425
473
  : await social.refuseRequest(agent, requester).catch((e) => ({ error: e.message }));
426
- if (r.error) return this.send(res, 404, { error: r.error });
427
- return this.send(res, 200, { ok: true, object: requester });
474
+ if (r.error) return reply(404, { error: r.error });
475
+ return reply(200, { ok: true, object: requester });
428
476
  }
429
477
 
430
478
  case 'Move':
431
- return this.send(res, 422, { error: 'moving the account is done on the admin surface, where it asks twice' });
479
+ return reply(422, { error: 'moving the account is done on the admin surface, where it asks twice' });
432
480
 
433
481
  default:
434
- return this.send(res, 422, { error: `no handler for ${activity.type}` });
482
+ return reply(422, { error: `no handler for ${activity.type}` });
435
483
  }
436
484
  }
437
485
  }
@@ -117,7 +117,15 @@ export class MastoApi {
117
117
  res.end(body);
118
118
  return true;
119
119
  };
120
- const ctx = { req, res, pathname, url, send };
120
+ // The address the client reached, so every self-URL handed back — the
121
+ // pagination links above all — names it rather than the pod's host.
122
+ const scheme = String(this.scheme
123
+ || (req.headers?.['x-forwarded-proto'] === 'https' || req.socket?.encrypted ? 'https' : 'http')).replace(/:.*$/, '');
124
+ let self = url;
125
+ try {
126
+ self = new URL(`${scheme}://${req.headers?.host || 'localhost'}${this.mount || ''}${pathname}${url.search || ''}`);
127
+ } catch { /* a Host nothing can parse: the links keep the placeholder origin */ }
128
+ const ctx = { req, res, pathname, url: self, send };
121
129
 
122
130
  if (await oauth.handle(this, ctx)) return true;
123
131
  if (!pathname.startsWith('/api/')) return false;
@@ -102,7 +102,9 @@ export function page(api, items, url, { limit = 20, max = 40, idOf = (s) => api.
102
102
  }
103
103
  const pageItems = items.slice(0, n);
104
104
  if (!pageItems.length) return { items: pageItems, headers: {} };
105
- const base = `http://${api.host}${url.pathname}`;
105
+ // `url` is the request as the client addressed it (index.mjs handle), so
106
+ // the links name that origin and mount, not the pod's host.
107
+ const base = url.origin + url.pathname;
106
108
  const q = (extra) => {
107
109
  const u = new URL(base);
108
110
  for (const [k, v] of url.searchParams) if (!['max_id', 'since_id', 'min_id'].includes(k)) u.searchParams.set(k, v);
@@ -222,7 +224,7 @@ export function status(api, s, { all } = {}) {
222
224
  created_at: s.published || new Date().toISOString(),
223
225
  in_reply_to_id: s.inReplyTo ? api.store.idFor(s.inReplyTo) : null,
224
226
  in_reply_to_account_id: null,
225
- sensitive: !!s.spoiler, spoiler_text: s.spoiler || '',
227
+ sensitive: !!(s.spoiler || s.sensitive), spoiler_text: s.spoiler || '',
226
228
  visibility: s.visibility || 'public', language: null,
227
229
  edited_at: s.editedAt || null,
228
230
  uri: s.noteId, url: s.link || s.noteId,
@@ -251,7 +253,7 @@ export function status(api, s, { all } = {}) {
251
253
  acct: bare.includes('@') ? bare : (host ? `${user}@${host}` : bare),
252
254
  };
253
255
  }),
254
- tags: [],
256
+ tags: (s.tags || []).map(t => ({ name: t.name, url: t.url })),
255
257
  emojis: (s.emojis || []).map(e => ({
256
258
  shortcode: e.shortcode, url: e.url, static_url: e.url, visible_in_picker: false,
257
259
  })),
@@ -37,6 +37,7 @@ export async function handle(api, ctx) {
37
37
  if (ready !== true) return send(422, { error: ready });
38
38
  }
39
39
  const spoilerText = String(body.spoiler_text || '').trim() || null;
40
+ const sensitive = body.sensitive === true || body.sensitive === 'true' || body.sensitive === '1';
40
41
  const mediaIds = [].concat(body.media_ids || body['media_ids[]'] || []).filter(Boolean);
41
42
  const media = api.store.getMedia();
42
43
  const attachments = mediaIds.map(id => media[id] && { id, ...media[id] }).filter(Boolean);
@@ -67,7 +68,7 @@ export async function handle(api, ctx) {
67
68
  const q = await api.agent.publisher.publishQuestion(body.status, {
68
69
  options: asking.options, multiple: asking.multiple,
69
70
  expiresAt: new Date(Date.now() + seconds * 1000).toISOString(),
70
- inReplyTo, visibility, spoilerText,
71
+ inReplyTo, visibility, spoilerText, sensitive,
71
72
  });
72
73
  return send(200, api.status(api.store.getStatuses().find(x => x.noteId === q.id)));
73
74
  } catch (e) {
@@ -98,7 +99,7 @@ export async function handle(api, ctx) {
98
99
  let note;
99
100
  try {
100
101
  note = await api.agent.publisher.publishNote(body.status,
101
- { inReplyTo, attachments, visibility, spoilerText });
102
+ { inReplyTo, attachments, visibility, spoilerText, sensitive });
102
103
  } catch (e) {
103
104
  // A direct message nobody could be found for: the sender's mistake to
104
105
  // fix, said in the client, not a fault of the server.
@@ -126,7 +127,7 @@ export async function handle(api, ctx) {
126
127
  const s = noteUrl && api.store.getStatuses().find(x => x.noteId === noteUrl);
127
128
  if (!s) return send(404, { error: 'Record not found' });
128
129
  return send(200, [{
129
- content: s.content || '', spoiler_text: s.spoiler || '', sensitive: !!s.spoiler,
130
+ content: s.content || '', spoiler_text: s.spoiler || '', sensitive: !!(s.spoiler || s.sensitive),
130
131
  created_at: s.editedAt || s.published, account: api.account(s.actor),
131
132
  media_attachments: (s.attachments || []).map(a => api.mediaJson(a)),
132
133
  emojis: [], poll: null,
@@ -152,8 +153,10 @@ export async function handle(api, ctx) {
152
153
  ? mediaIds.map(id => media[id] && { id, ...media[id] }).filter(Boolean)
153
154
  : null; // null: keep what the post has
154
155
  const spoilerText = String(body.spoiler_text || '').trim() || null;
156
+ const sensitive = body.sensitive === undefined ? null
157
+ : (body.sensitive === true || body.sensitive === 'true' || body.sensitive === '1');
155
158
  const patched = await api.agent.publisher.updateNote(s,
156
- { content: body.status, spoilerText, attachments });
159
+ { content: body.status, spoilerText, sensitive, attachments });
157
160
  return send(200, api.status(patched || s));
158
161
  }
159
162
  if (mStatus && req.method === 'DELETE') {
@@ -1,7 +1,7 @@
1
1
  // oidc-auth.mjs — who may drive the C2S surface. Two proofs are accepted:
2
2
  // the Mastodon facade's bearer (minted on this machine, so its holder is the
3
- // operator by construction), or a Solid-OIDC token — DPoP-bound when the
4
- // client sends the proof header — verified by the same library the wider
3
+ // operator by construction), or a Solid-OIDC token with its DPoP proof,
4
+ // which Solid-OIDC requires, verified by the same library the wider
5
5
  // Solid world uses. The token alone is not enough: the WebID it names must
6
6
  // be THIS identity's owner, an authorization step the token does not carry.
7
7
  //
@@ -25,6 +25,13 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
25
25
  if (!req.headers.authorization) {
26
26
  return { ok: false, status: 401, error: 'authentication required: a Solid-OIDC token (DPoP) or this agent\'s own bearer' };
27
27
  }
28
+ // Solid-OIDC binds the token to a key and has the client prove it holds
29
+ // that key on every request. A token shown without its proof is a token
30
+ // anyone who saw it could show.
31
+ if (!req.headers.dpop) {
32
+ return { ok: false, status: 401, error: 'a Solid-OIDC token must come with its DPoP proof',
33
+ headers: { 'www-authenticate': 'DPoP' } };
34
+ }
28
35
  let webid;
29
36
  try {
30
37
  const v = await loadVerifier();
@@ -38,7 +45,7 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
38
45
  }://${req.headers.host}${mount}${pathname}`;
39
46
  ({ webid } = await v(
40
47
  req.headers.authorization,
41
- req.headers.dpop ? { header: req.headers.dpop, method: req.method, url: htu } : undefined,
48
+ { header: req.headers.dpop, method: req.method, url: htu },
42
49
  ));
43
50
  } catch (e) {
44
51
  log(`c2s auth: token rejected — ${e.message}`);
package/lib/core/as2.mjs CHANGED
@@ -19,7 +19,7 @@
19
19
  // common shape: one that skipped it would not be read as JSON-LD, and would
20
20
  // produce no graph, so nothing could be validated against it.
21
21
  //
22
- // CONTEXTS ARE NEVER FETCHED. The loader serves the fourteen the fediverse
22
+ // CONTEXTS ARE NEVER FETCHED. The loader serves the fifteen the fediverse
23
23
  // actually uses and refuses every other URL. That refusal is the point: this
24
24
  // code runs on documents a stranger wrote, in the same path the SSRF guards
25
25
  // protect, and expanding one would otherwise mean dereferencing a URL of their