fedipod 1.6.0 → 1.8.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 (52) hide show
  1. package/README.md +9 -3
  2. package/device-agent.md +99 -0
  3. package/gateway.md +16 -0
  4. package/groups.md +1 -1
  5. package/gui.md +1 -1
  6. package/lib/client/c2s.mjs +95 -61
  7. package/lib/client/masto/index.mjs +7 -1
  8. package/lib/client/masto/timelines.mjs +1 -1
  9. package/lib/client/oidc-auth.mjs +5 -3
  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/intake/index.mjs +32 -5
  14. package/lib/core/publisher/index.mjs +15 -1
  15. package/lib/core/publisher/notes.mjs +84 -2
  16. package/lib/core/publisher/questions.mjs +1 -0
  17. package/lib/core/publisher/restore.mjs +27 -2
  18. package/lib/core/social.mjs +1 -0
  19. package/lib/core/store.mjs +4 -0
  20. package/lib/core/wire.mjs +20 -13
  21. package/lib/device/admin/routes/gateway.mjs +1 -1
  22. package/lib/device/admin/surface.mjs +25 -17
  23. package/lib/device/cli/commands/setup.mjs +9 -1
  24. package/lib/device/setup.mjs +6 -0
  25. package/lib/gateway/front-core.mjs +66 -12
  26. package/lib/gateway/gateway-core.mjs +40 -0
  27. package/lib/pod/actor.mjs +2 -2
  28. package/lib/pod/root.mjs +11 -0
  29. package/lib/pod/transport.mjs +9 -3
  30. package/lib/server/embed.mjs +23 -6
  31. package/package.json +2 -2
  32. package/run-agent.mjs +10 -1
  33. package/scripts/refresh-contexts.mjs +5 -0
  34. package/vendor/gate.cjs +5 -2
  35. package/web/admin/index.html +2 -0
  36. package/web/admin/upkeep.js +9 -1
  37. package/web/app/README.md +1 -1
  38. package/web/app/agent.mjs +11 -1
  39. package/web/app/boot.mjs +49 -10
  40. package/web/app/dist/boot.js +89 -37
  41. package/web/app/dist/boot.js.map +3 -3
  42. package/web/app/dist/sw.js +1326 -662
  43. package/web/app/dist/sw.js.map +4 -4
  44. package/web/app/index.html +16 -6
  45. package/web/app/signup.mjs +3 -1
  46. package/web/app/site/admin/index.html +2 -0
  47. package/web/app/site/admin/upkeep.js +9 -1
  48. package/web/app/site/boot.js +89 -37
  49. package/web/app/site/index.html +16 -6
  50. package/web/app/site/sw.js +1326 -662
  51. package/web/front/#new-account.html# +0 -43
  52. 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.
@@ -41,6 +40,8 @@ There are also a number of [other ways to run FediPod](#other-ways-to-run-fedipo
41
40
  Your account then opens in the client. To use it from another browser, go to
42
41
  https://fedipod.net, enter your address, sign in at your pod, and unlock your
43
42
  key with your password once on that browser.
43
+ If you have changed your pod password since you signed up, choose "make a new
44
+ signing key" on that screen and use the password you use now.
44
45
 
45
46
  ## What you can do
46
47
 
@@ -70,6 +71,11 @@ hands back stays in your browser unless you choose to keep it on your pod.
70
71
  domain blocks import from the manage page. Your old account can be listed as
71
72
  an alias, so a Move from it lands here.
72
73
 
74
+ **Posting from another app.** Any app that speaks ActivityPub
75
+ client-to-server, dokieli for one, can post as you. It sends to the outbox
76
+ address in your actor document, which your WebID profile also names, signed in
77
+ at your pod. The post goes out the next time you open fedipod.net.
78
+
73
79
  **The manage page.** `manage account` in the bar opens it: your profile,
74
80
  aliases, the gateway, key rotation, recovering posts, parking, moving to
75
81
  another server, retiring, and clearing a backlog. It is the same interface
@@ -0,0 +1,99 @@
1
+ # The DeviceAgent
2
+
3
+ FediPod can also run as a program on your own machine, in front of the same
4
+ kind of pod. It does everything the browser version at fedipod.net does, plus
5
+ what a browser tab cannot: it keeps running while no tab is open, so scheduled
6
+ posts go out and push notifications reach you; it serves the Mastodon streaming
7
+ API, so clients update live; any Mastodon client, phone app or desktop, can
8
+ connect to it; and it can host a [group](groups.md).
9
+
10
+ ## Requirements
11
+
12
+ - Node 20 or newer.
13
+ - A Solid pod, either with a host name of its own, such as
14
+ `https://alice.solidcommunity.net/`, or on a suffix-based host, such as
15
+ `https://server.example/alice/`. A pod at its own host carries its address on
16
+ the pod; a pod on a suffix-based host takes its address at a gateway,
17
+ `@handle@fedipod.net`, with the posts, key and data staying on the pod.
18
+ - Followers-only and direct posts need a pod that enforces WAC access control;
19
+ on one that does not, the composer refuses those two and says why.
20
+ - While the agent is off, your mail waits on your pod's host. Run it as a
21
+ service, or attach to a gateway, so it does not pile up there.
22
+
23
+ ## Installing
24
+
25
+ ```
26
+ npm install -g fedipod
27
+ ```
28
+
29
+ ## Running
30
+
31
+ Run `fedipod start`. Add a port to change the local agent's port, for example
32
+ `fedipod start --port 8081`; the default is 8030. Then point any browser at
33
+ `https://localhost:8030`, or the port you chose, and the setup pages take it
34
+ from there.
35
+
36
+ ## Running as a service
37
+
38
+ ```
39
+ fedipod install-service
40
+ ```
41
+
42
+ It registers every identity on this machine, one service each, so all of your
43
+ actors start at boot. An identity running in a terminal is stopped and taken
44
+ over by its service. `fedipod uninstall-service` reverses it.
45
+
46
+ ## Managing
47
+
48
+ Posts, logs, parking, moving, transferring and the rest are on the
49
+ [admin interface](gui.md); starting, stopping and what a page cannot do are in
50
+ [CLI admin](cli.md). The admin tools also create other actors, groups or
51
+ persons. You may have as many as you want on one machine, each with a pod of
52
+ its own.
53
+
54
+ Every agent checks once a day whether a newer FediPod is published. When one
55
+ exists, the record page offers **Update**, and `fedipod update` does the same
56
+ from the terminal. `AP_UPDATE_CHECK=0` turns the check off.
57
+
58
+ ## A gateway account
59
+
60
+ Most of what a Fediverse inbox receives is broadcast noise. A
61
+ [gateway](gateway.md) is a shared, always-on door that verifies each delivery,
62
+ drops the junk, and passes the rest to your pod, while your key and data stay
63
+ on your pod. There is a free one at [fedipod.net](https://fedipod.net/).
64
+ Attaching or detaching is a few wizard-guided clicks from your agent, and it
65
+ takes the mail load off your pod's host.
66
+
67
+ ## Clients
68
+
69
+ The bundled client is [Phanpy](https://github.com/cheeaun/phanpy) (MIT, by
70
+ Chee Aun), served by the agent itself, and logging in is one click. If you
71
+ ever enter the instance by hand, use the address on the record's **local
72
+ host** row.
73
+
74
+ - **Other web clients**: drop any static Mastodon client dist into
75
+ `ui/<name>/` and it is served at `/<name>/`; see `ui/README.md`.
76
+ - **Desktop and phone clients** (Tuba, Whalebird, and the like): add
77
+ `https://localhost:8030`, or your agent's port, as a custom instance.
78
+ - **Streaming**: the agent serves the Mastodon streaming API at
79
+ `/api/v1/streaming`, so clients update live instead of polling.
80
+ - **Web push**: notifications reach you while the client is closed.
81
+ - **Scheduled posts** go out at the time you picked.
82
+
83
+ Polls, content warnings, editing, all four visibility levels, direct
84
+ messages, bookmarks, favourites, lists, keyword filters, pinned posts,
85
+ blocking and muting, and custom emojis work as in the browser version.
86
+
87
+ ## Bluesky, and your other Fediverse accounts
88
+
89
+ A Bluesky connection lets the agent drive an existing Bluesky, or other
90
+ ATProto, account alongside your Fediverse identity: public posts are
91
+ cross-posted as a mirror, with a toggle to turn it off; Bluesky replies and
92
+ activity flow into your timeline; you can like, boost and reply to Bluesky
93
+ posts. Direct messages to Bluesky are not supported.
94
+
95
+ An account on Mastodon or any server speaking the Mastodon API can be
96
+ connected from the **Other identities** row of the admin page. Its home
97
+ timeline and notifications join your feed, a post both accounts see appears
98
+ once, and favouriting, boosting and replying act as the account the post came
99
+ through. The token it hands back stays on this machine.
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
@@ -59,7 +59,7 @@ A followed group's own announced deletion of a post it carried to you is honoure
59
59
  ## Inviting people
60
60
 
61
61
  A group has a page anyone can open, at `ap/profile.html` under its pod's
62
- app container — `<pod>/activitypods-js/ap/profile.html`. It
62
+ app container — `<pod>/fedipod/ap/profile.html`. It
63
63
  carries the group's address and a Follow box that sends a visitor to their
64
64
  own server's follow screen, so it is the link to put where people will find
65
65
  it. Posts the group carries appear in members' timelines as the group
package/gui.md CHANGED
@@ -46,7 +46,7 @@ its own port.
46
46
  ## Sharing an account
47
47
 
48
48
  Each identity has a page anyone can open, at `ap/profile.html` under its pod
49
- — for example `https://your-pod.example/activitypods-js/ap/profile.html`. It
49
+ — for example `https://your-pod.example/fedipod/ap/profile.html`. It
50
50
  shows the name, bio and address, and offers a Follow box: a visitor types
51
51
  their own server and lands on that server's follow screen. Hand out that
52
52
  link, or the `@name@host` address itself, which works in the search box of
@@ -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) {
@@ -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,47 @@ 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
+ async _dispatch(activity, { slug, raw, reply }) {
237
266
  const agent = this.agent;
238
267
  const object = typeof activity.object === 'object' && activity.object !== null
239
268
  ? activity.object : null;
@@ -242,18 +271,22 @@ export class C2S {
242
271
  switch (activity.type) {
243
272
  case 'Create': {
244
273
  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
- }
274
+ if (!object) return reply(422, { error: 'a Create carries the object it creates' });
248
275
  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)' });
276
+ // Not a Note and not a poll: stored as sent, under this actor, and
277
+ // the Create around it delivered — a Web Annotation, for one.
278
+ if (makes !== 'Note' && makes !== 'Question') {
279
+ const asSent = raw && typeof raw === 'object' && !Array.isArray(raw)
280
+ ? (ACTIVITY_TYPES.has(raw.type) ? (raw.object && typeof raw.object === 'object' ? raw.object : null) : raw)
281
+ : null;
282
+ const made = await agent.publisher.publishObject(asSent || object, { visibility, slug });
283
+ return reply(201, { id: made.createId, object: made.id }, { location: made.createId });
251
284
  }
252
285
  // `source.content` is the client's plain text when it sends one; bare
253
286
  // `content` is TREATED as plain text and escaped — markup survives as
254
287
  // visible characters rather than as markup. Documented v1 limit.
255
288
  const text = String(object.source?.content ?? object.content ?? '');
256
- if (!text.trim()) return this.send(res, 422, { error: 'the note has no content' });
289
+ if (!text.trim()) return reply(422, { error: 'the note has no content' });
257
290
 
258
291
  // A Question is a poll: the choices are in oneOf (pick one) or anyOf
259
292
  // (pick several), each naming itself, and endTime is when it shuts.
@@ -270,11 +303,11 @@ export class C2S {
270
303
  visibility,
271
304
  spoilerText: object.summary || null,
272
305
  });
273
- return this.send(res, 201,
306
+ return reply(201,
274
307
  { id: wire.createActivityId(question.id), object: question.id },
275
308
  { location: wire.createActivityId(question.id) });
276
309
  } catch (e) {
277
- return this.send(res, 422, { error: e.message });
310
+ return reply(422, { error: e.message });
278
311
  }
279
312
  }
280
313
 
@@ -287,22 +320,23 @@ export class C2S {
287
320
  attachments,
288
321
  visibility,
289
322
  spoilerText: object.summary || null,
323
+ slug,
290
324
  });
291
- return this.send(res, 201, { id: wire.createActivityId(note.id), object: note.id },
325
+ return reply(201, { id: wire.createActivityId(note.id), object: note.id },
292
326
  { location: wire.createActivityId(note.id) });
293
327
  }
294
328
 
295
329
  case 'Update': {
296
330
  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' });
331
+ return reply(422, { error: 'edit the profile on the admin surface; actor updates are not taken here' });
298
332
  }
299
333
  const s = this.byIri(objectId);
300
- if (!s) return this.send(res, 404, { error: 'no such note here' });
334
+ if (!s) return reply(404, { error: 'no such note here' });
301
335
  if (s.actor !== this.urls.actor || s.kind !== 'post') {
302
- return this.send(res, 403, { error: 'not your note' });
336
+ return reply(403, { error: 'not your note' });
303
337
  }
304
338
  const text = String(object?.source?.content ?? object?.content ?? '');
305
- if (!text.trim()) return this.send(res, 422, { error: 'the edit has no content' });
339
+ if (!text.trim()) return reply(422, { error: 'the edit has no content' });
306
340
  const attachments = object?.attachment !== undefined
307
341
  ? arr(object.attachment).map((a) => ({
308
342
  url: a?.url, mediaType: a?.mediaType,
@@ -312,52 +346,52 @@ export class C2S {
312
346
  await agent.publisher.updateNote(s, {
313
347
  content: text, spoilerText: object?.summary || null, attachments,
314
348
  });
315
- return this.send(res, 200, { ok: true, object: s.noteId });
349
+ return reply(200, { ok: true, object: s.noteId });
316
350
  }
317
351
 
318
352
  case 'Delete': {
319
353
  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' });
354
+ return reply(422, { error: 'retiring the actor is done on the admin surface, where it asks twice' });
321
355
  }
322
356
  const s = this.byIri(objectId);
323
- if (!s) return this.send(res, 404, { error: 'no such note here' });
357
+ if (!s) return reply(404, { error: 'no such note here' });
324
358
  if (s.actor !== this.urls.actor || s.kind !== 'post') {
325
- return this.send(res, 403, { error: 'not your note' });
359
+ return reply(403, { error: 'not your note' });
326
360
  }
327
361
  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 });
362
+ if (!r.ok) return reply(502, { error: r.error, stillPublished: r.stillPublished });
363
+ return reply(200, { ok: true });
330
364
  }
331
365
 
332
366
  case 'Follow': {
333
- if (!objectId) return this.send(res, 400, { error: 'whom? object must name an actor' });
367
+ if (!objectId) return reply(400, { error: 'whom? object must name an actor' });
334
368
  // An acct: form or bare handle resolves through WebFinger; an https
335
369
  // IRI is fetched directly.
336
370
  if (/^acct:|^@|^[^/@]+@[^/@]+$/.test(objectId) && !/^https?:/.test(objectId)) {
337
371
  const r = await social.followHandle(agent, objectId.replace(/^acct:/, ''));
338
372
  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 },
373
+ return reply(201, { id: rec?.followActivity?.id, object: r.actor },
340
374
  rec?.followActivity?.id ? { location: rec.followActivity.id } : {});
341
375
  }
342
376
  const doc = await social.followActor(agent, objectId);
343
377
  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 },
378
+ return reply(201, { id: rec?.followActivity?.id, object: doc.id },
345
379
  rec?.followActivity?.id ? { location: rec.followActivity.id } : {});
346
380
  }
347
381
 
348
382
  case 'Like': {
349
383
  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' });
384
+ if (!s) return reply(422, { error: 'that note is not held here — like what the timeline holds' });
351
385
  const updated = await social.favourite(agent, s);
352
- return this.send(res, 201, { id: updated.likeActivity?.id, object: s.noteId },
386
+ return reply(201, { id: updated.likeActivity?.id, object: s.noteId },
353
387
  updated.likeActivity?.id ? { location: updated.likeActivity.id } : {});
354
388
  }
355
389
 
356
390
  case 'Announce': {
357
391
  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' });
392
+ if (!s) return reply(422, { error: 'that note is not held here — boost what the timeline holds' });
359
393
  const updated = await social.reblog(agent, s);
360
- return this.send(res, 201, { id: updated.announceActivity?.id, object: s.noteId },
394
+ return reply(201, { id: updated.announceActivity?.id, object: s.noteId },
361
395
  updated.announceActivity?.id ? { location: updated.announceActivity.id } : {});
362
396
  }
363
397
 
@@ -368,37 +402,37 @@ export class C2S {
368
402
  const innerId = idOf(activity.object);
369
403
  if (inner?.type === 'Block') {
370
404
  const target = idOf(inner.object);
371
- if (!target) return this.send(res, 400, { error: 'unblock whom?' });
405
+ if (!target) return reply(400, { error: 'unblock whom?' });
372
406
  await social.unblockActor(agent, target);
373
- return this.send(res, 200, { ok: true, object: target });
407
+ return reply(200, { ok: true, object: target });
374
408
  }
375
409
  const statuses = this.store.getStatuses();
376
410
  let s = innerId ? statuses.find((x) => x.likeActivity?.id === innerId) : null;
377
411
  if (!s && inner?.type === 'Like') s = this.byIri(idOf(inner.object));
378
412
  if (s?.favourited) {
379
413
  const updated = await social.unfavourite(agent, s);
380
- return this.send(res, 200, { ok: true, object: updated.noteId });
414
+ return reply(200, { ok: true, object: updated.noteId });
381
415
  }
382
416
  s = innerId ? statuses.find((x) => x.announceActivity?.id === innerId) : null;
383
417
  if (!s && inner?.type === 'Announce') s = this.byIri(idOf(inner.object));
384
418
  if (s?.reblogged) {
385
419
  const updated = await social.unreblog(agent, s);
386
- return this.send(res, 200, { ok: true, object: updated.noteId });
420
+ return reply(200, { ok: true, object: updated.noteId });
387
421
  }
388
422
  const following = this.store.getContacts().following;
389
423
  const rec = following.find((f) => f.followActivity?.id === innerId)
390
424
  || (inner?.type === 'Follow' ? following.find((f) => f.actor === idOf(inner.object)) : null);
391
425
  if (rec) {
392
426
  await social.unfollowActor(agent, rec.actor);
393
- return this.send(res, 200, { ok: true, object: rec.actor });
427
+ return reply(200, { ok: true, object: rec.actor });
394
428
  }
395
- return this.send(res, 422, { error: 'nothing here matches what that Undo names' });
429
+ return reply(422, { error: 'nothing here matches what that Undo names' });
396
430
  }
397
431
 
398
432
  case 'Block': {
399
- if (!objectId) return this.send(res, 400, { error: 'block whom? object must name an actor' });
433
+ if (!objectId) return reply(400, { error: 'block whom? object must name an actor' });
400
434
  await social.blockActor(agent, objectId);
401
- return this.send(res, 200, { ok: true, object: objectId });
435
+ return reply(200, { ok: true, object: objectId });
402
436
  }
403
437
 
404
438
  case 'Add':
@@ -406,12 +440,12 @@ export class C2S {
406
440
  // The one collection a client may edit is the pins (§7.6/§7.9 in the
407
441
  // other direction): target must be the featured collection.
408
442
  if (idOf(activity.target) !== this.urls.featured) {
409
- return this.send(res, 422, { error: 'the featured collection is the one Add/Remove edits here' });
443
+ return reply(422, { error: 'the featured collection is the one Add/Remove edits here' });
410
444
  }
411
445
  const s = this.byIri(objectId);
412
- if (!s) return this.send(res, 404, { error: 'no such note here' });
446
+ if (!s) return reply(404, { error: 'no such note here' });
413
447
  const updated = await social.pinStatus(agent, s, activity.type === 'Add');
414
- return this.send(res, 200, { ok: true, object: updated.noteId, pinned: !!updated.pinned });
448
+ return reply(200, { ok: true, object: updated.noteId, pinned: !!updated.pinned });
415
449
  }
416
450
 
417
451
  case 'Accept':
@@ -419,19 +453,19 @@ export class C2S {
419
453
  // Answering a held follow request: the object is the Follow (or the
420
454
  // requester). Which request is meant comes from the Follow's actor.
421
455
  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' });
456
+ if (!requester) return reply(400, { error: 'whose request? object must name the Follow or its actor' });
423
457
  const r = activity.type === 'Accept'
424
458
  ? await social.admitRequest(agent, requester).catch((e) => ({ error: e.message }))
425
459
  : 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 });
460
+ if (r.error) return reply(404, { error: r.error });
461
+ return reply(200, { ok: true, object: requester });
428
462
  }
429
463
 
430
464
  case 'Move':
431
- return this.send(res, 422, { error: 'moving the account is done on the admin surface, where it asks twice' });
465
+ return reply(422, { error: 'moving the account is done on the admin surface, where it asks twice' });
432
466
 
433
467
  default:
434
- return this.send(res, 422, { error: `no handler for ${activity.type}` });
468
+ return reply(422, { error: `no handler for ${activity.type}` });
435
469
  }
436
470
  }
437
471
  }
@@ -32,8 +32,14 @@ export { attachmentType, extensionFor } from './media.mjs';
32
32
 
33
33
  export class MastoApi {
34
34
  constructor({ agent, log = console.log, allowed = null, scheme = null, embedded = false,
35
- streaming = true, webPush = true, scheduling = true }) {
35
+ mount = '', streaming = true, webPush = true, scheduling = true }) {
36
36
  this.agent = agent;
37
+ // The path this identity's surface answers under, when it shares its origin
38
+ // with others (a suffix pod, e.g. `/aisha`). Empty for a host-root or
39
+ // subdomain pod. Folded into the self-URLs the client is handed —
40
+ // pagination links, the OAuth issuer — so they name the address the client
41
+ // actually reached.
42
+ this.mount = mount;
37
43
  // A server-hosted identity has no CLI of its own, so the advice this gives
38
44
  // when it refuses has to name the route that identity really has.
39
45
  this.embedded = embedded;
@@ -254,7 +254,7 @@ export async function handle(api, ctx) {
254
254
 
255
255
  // A client pages by following these rather than by guessing ids.
256
256
  if (page.length) {
257
- const base = `${api.scheme || (req.socket?.encrypted ? 'https' : 'http')}://${req.headers.host}${pathname}`;
257
+ const base = `${api.scheme || (req.socket?.encrypted ? 'https' : 'http')}://${req.headers.host}${api.mount || ''}${pathname}`;
258
258
  const link = (params) => {
259
259
  const u = new URL(base);
260
260
  for (const [k, v] of q) if (k !== 'max_id' && k !== 'since_id' && k !== 'min_id') u.searchParams.append(k, v);
@@ -8,7 +8,7 @@
8
8
  // The verifier is injected so offline tests stub it, and wrapped so the
9
9
  // library (CJS, older jose) can be replaced without touching any caller.
10
10
 
11
- export function makeC2sAuth({ agent, masto = null, verifier = null, log = () => {}, scheme = null }) {
11
+ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () => {}, scheme = null, mount = '' }) {
12
12
  let verify = verifier;
13
13
  const loadVerifier = async () => {
14
14
  if (!verify) {
@@ -31,9 +31,11 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
31
31
  // The URL the client signed its proof over. The Host header already
32
32
  // passed the Authorities firewall, so whichever alias the client used
33
33
  // (localhost, 127.0.0.1, the named origin) is one this agent answers on;
34
- // the scheme is whichever listener the request arrived on.
34
+ // the scheme is whichever listener the request arrived on. `pathname` is
35
+ // relative to this identity's mount, so a suffix pod folds the mount back
36
+ // in — the client signed over the full path it actually requested.
35
37
  const htu = `${scheme ? scheme.replace(/:$/u, '') : req.socket?.encrypted ? 'https' : 'http'
36
- }://${req.headers.host}${pathname}`;
38
+ }://${req.headers.host}${mount}${pathname}`;
37
39
  ({ webid } = await v(
38
40
  req.headers.authorization,
39
41
  req.headers.dpop ? { header: req.headers.dpop, method: req.method, url: htu } : undefined,