fedipod-server 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +22 -6
  2. package/dist/claims.d.ts +8 -0
  3. package/dist/claims.js +10 -0
  4. package/dist/handler.d.ts +13 -0
  5. package/dist/handler.js +56 -11
  6. package/dist/handler.jsonld +8 -0
  7. package/dist/store-pod.js +18 -4
  8. package/lib/{c2s.mjs → client/c2s.mjs} +10 -3
  9. package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
  10. package/lib/client/masto/accounts.mjs +264 -0
  11. package/lib/client/masto/body.mjs +69 -0
  12. package/lib/client/masto/index.mjs +183 -0
  13. package/lib/client/masto/instance.mjs +104 -0
  14. package/lib/client/masto/media.mjs +133 -0
  15. package/lib/client/masto/oauth.mjs +599 -0
  16. package/lib/client/masto/render.mjs +459 -0
  17. package/lib/client/masto/statuses.mjs +331 -0
  18. package/lib/client/masto/timelines.mjs +316 -0
  19. package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
  20. package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
  21. package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
  22. package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
  23. package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
  24. package/lib/{import.mjs → connections/import.mjs} +1 -1
  25. package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +3 -3
  26. package/lib/connections/vault.mjs +114 -0
  27. package/lib/core/as2.mjs +170 -0
  28. package/lib/core/contexts/activitystreams.json +379 -0
  29. package/lib/core/contexts/did-v1.json +57 -0
  30. package/lib/core/contexts/fep-5711.json +36 -0
  31. package/lib/core/contexts/gotosocial.json +86 -0
  32. package/lib/core/contexts/identity-v1.json +152 -0
  33. package/lib/core/contexts/index.mjs +45 -0
  34. package/lib/core/contexts/join-lemmy.json +33 -0
  35. package/lib/core/contexts/joinmastodon.json +28 -0
  36. package/lib/core/contexts/map.json +16 -0
  37. package/lib/core/contexts/miscellany.json +19 -0
  38. package/lib/core/contexts/schemaorg.json +8845 -0
  39. package/lib/core/contexts/security-data-integrity-v1.json +78 -0
  40. package/lib/core/contexts/security-data-integrity-v2.json +81 -0
  41. package/lib/core/contexts/security-multikey-v1.json +35 -0
  42. package/lib/core/contexts/security-v1.json +74 -0
  43. package/lib/core/contexts/webfinger.json +10 -0
  44. package/lib/{deliver.mjs → core/deliver.mjs} +2 -2
  45. package/lib/core/graphview.mjs +269 -0
  46. package/lib/core/intake/activities.mjs +437 -0
  47. package/lib/core/intake/activity.mjs +240 -0
  48. package/lib/core/intake/channel.mjs +144 -0
  49. package/lib/core/intake/group.mjs +222 -0
  50. package/lib/core/intake/index.mjs +629 -0
  51. package/lib/core/intake/notes.mjs +288 -0
  52. package/lib/core/intake/verify.mjs +142 -0
  53. package/lib/{keys.mjs → core/keys.mjs} +1 -1
  54. package/lib/core/publisher/collections.mjs +229 -0
  55. package/lib/core/publisher/index.mjs +421 -0
  56. package/lib/core/publisher/notes.mjs +188 -0
  57. package/lib/core/publisher/questions.mjs +233 -0
  58. package/lib/core/publisher/restore.mjs +199 -0
  59. package/lib/core/shapes/activitystreams.ttl +129 -0
  60. package/lib/core/shapes/index.mjs +107 -0
  61. package/lib/core/shapes/shapes-text.mjs +13 -0
  62. package/lib/{social.mjs → core/social.mjs} +2 -2
  63. package/lib/{store.mjs → core/store.mjs} +4 -0
  64. package/lib/{wire.mjs → core/wire.mjs} +2 -2
  65. package/lib/device/admin/index.mjs +13 -0
  66. package/lib/device/admin/origins.mjs +35 -0
  67. package/lib/device/admin/routes/connections.mjs +144 -0
  68. package/lib/device/admin/routes/gateway.mjs +199 -0
  69. package/lib/device/admin/routes/lifecycle.mjs +191 -0
  70. package/lib/device/admin/routes/owner.mjs +322 -0
  71. package/lib/device/admin/routes/setup.mjs +393 -0
  72. package/lib/device/admin/routes/social.mjs +188 -0
  73. package/lib/device/admin/server.mjs +95 -0
  74. package/lib/device/admin/static.mjs +244 -0
  75. package/lib/device/admin/surface.mjs +274 -0
  76. package/lib/device/cli/commands/account.mjs +586 -0
  77. package/lib/device/cli/commands/run.mjs +278 -0
  78. package/lib/device/cli/commands/service.mjs +221 -0
  79. package/lib/device/cli/commands/setup.mjs +410 -0
  80. package/lib/device/cli/commands/state.mjs +559 -0
  81. package/lib/device/cli/context.mjs +288 -0
  82. package/lib/{migrate.mjs → device/migrate.mjs} +1 -1
  83. package/lib/{remote.mjs → device/remote.mjs} +3 -3
  84. package/lib/{setup.mjs → device/setup.mjs} +3 -3
  85. package/lib/{update.mjs → device/update.mjs} +1 -1
  86. package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
  87. package/lib/{front-core.mjs → gateway/front-core.mjs} +3 -3
  88. package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +1 -1
  89. package/lib/{httpsig.mjs → gateway/httpsig.mjs} +1 -1
  90. package/lib/server/embed.mjs +405 -0
  91. package/lib/{links.mjs → shared/links.mjs} +1 -1
  92. package/lib/{ua.mjs → shared/ua.mjs} +1 -1
  93. package/package.json +1 -1
  94. package/run-agent.mjs +33 -25
  95. package/web/admin/actors.js +145 -0
  96. package/web/admin/common.js +23 -0
  97. package/web/admin/connections.js +112 -0
  98. package/web/admin/gateway.js +111 -0
  99. package/web/admin/group.js +258 -0
  100. package/web/admin/index.html +7 -1
  101. package/web/admin/record.js +378 -0
  102. package/web/admin/setup/index.html +1 -0
  103. package/web/admin/setup/setup.js +2 -13
  104. package/web/admin/upkeep.js +170 -0
  105. package/web/app/README.md +6 -6
  106. package/web/app/admin-facade.mjs +3 -3
  107. package/web/app/agent.mjs +14 -16
  108. package/web/app/atproto-browser.mjs +1 -1
  109. package/web/app/boot.mjs +2 -3
  110. package/web/app/deliver-relay.mjs +1 -1
  111. package/web/app/dist/boot.js +22 -3
  112. package/web/app/dist/boot.js.map +2 -2
  113. package/web/app/dist/sw.js +21913 -5446
  114. package/web/app/dist/sw.js.map +4 -4
  115. package/web/app/fediacct-browser.mjs +1 -1
  116. package/web/app/keys-browser.mjs +27 -4
  117. package/web/app/shims/shapes-text.mjs +8 -0
  118. package/web/app/signup.mjs +2 -3
  119. package/web/app/site/admin/actors.js +145 -0
  120. package/web/app/site/admin/common.js +23 -0
  121. package/web/app/site/admin/connections.js +112 -0
  122. package/web/app/site/admin/gateway.js +111 -0
  123. package/web/app/site/admin/group.js +258 -0
  124. package/web/app/site/admin/index.html +7 -1
  125. package/web/app/site/admin/record.js +378 -0
  126. package/web/app/site/admin/setup/index.html +1 -0
  127. package/web/app/site/admin/setup/setup.js +2 -13
  128. package/web/app/site/admin/upkeep.js +170 -0
  129. package/web/app/site/boot.js +22 -3
  130. package/web/app/site/sw.js +21913 -5446
  131. package/web/app/sw-src.mjs +17 -2
  132. package/lib/admin.mjs +0 -1913
  133. package/lib/embed.mjs +0 -220
  134. package/lib/intake.mjs +0 -1981
  135. package/lib/mastoapi.mjs +0 -2284
  136. package/lib/publisher.mjs +0 -1192
  137. package/web/admin/admin.js +0 -1181
  138. package/web/app/site/admin/admin.js +0 -1181
  139. /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
  140. /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
  141. /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
  142. /package/lib/{lease.mjs → core/lease.mjs} +0 -0
  143. /package/lib/{polls.mjs → core/polls.mjs} +0 -0
  144. /package/lib/{proof.mjs → core/proof.mjs} +0 -0
  145. /package/lib/{storage.mjs → core/storage.mjs} +0 -0
  146. /package/lib/{account.mjs → device/account.mjs} +0 -0
  147. /package/lib/{certs.mjs → device/certs.mjs} +0 -0
  148. /package/lib/{export-collections.mjs → device/export-collections.mjs} +0 -0
  149. /package/lib/{home.mjs → device/home.mjs} +0 -0
  150. /package/lib/{ports.mjs → device/ports.mjs} +0 -0
  151. /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
  152. /package/lib/{safefetch.mjs → shared/safefetch.mjs} +0 -0
package/README.md CHANGED
@@ -9,6 +9,13 @@ account: it accepts follows, delivers their posts, and serves their Mastodon
9
9
  client at the pod's own address. Signing up is the only way an account is
10
10
  made, and opting out is the only way one ends.
11
11
 
12
+ Every delivery to an account is checked as it arrives. The server that stores
13
+ the inbox is the server the other side's POST reaches, so the request's
14
+ signature is verified there and the result is written beside the activity. A
15
+ delivery signed with the wrong key is dropped. One with no signature is kept
16
+ and the account confirms the sender through the sender's own actor document
17
+ before acting on it. There is nothing to configure.
18
+
12
19
  With nothing configured beyond the defaults, installing the component changes
13
20
  nothing about how the server serves pods. All pods, whether or not they opt-in
14
21
  to being a Fediverse account, behave as Solid pods.
@@ -78,15 +85,24 @@ and stop with the server, and on the websocket handler list for the live feed.
78
85
 
79
86
  An identity is provisioned when its owner opts in: its name is the pod's
80
87
  subdomain label, and it publishes an actor, a signing key and WebFinger on the
81
- pod itself. Its state lives on the pod. Its signing key lives in
82
- `agentDataDir`, one directory per identity and beside it,
83
- `door-secret.json`: the secret guarding that identity's own pages. Each
84
- identity has its own; one owner's secret opens nobody else's door.
88
+ pod itself. Everything it is made of lives on its pod its state, its private
89
+ signing key, the secret guarding its own pages, and the credentials for any
90
+ accounts its owner connects on other servers. Each identity has its own
91
+ secret; one owner's opens nobody else's door. The opt-in reply is where the
92
+ owner is given it.
93
+
94
+ That is the difference from running FediPod on your own machine, where those
95
+ credentials stay on the machine and never reach the pod. Here the machine is
96
+ the pod's server, so a credential left on it is a connection its owner loses
97
+ the day they take their pod elsewhere.
98
+
99
+ `agentDataDir` holds what is left: one directory per identity, with the file
100
+ naming the pod the identity runs on. Nothing private is in it.
85
101
 
86
102
  | Setting | What it is |
87
103
  |---|---|
88
104
  | `agentRuntimeOptIn` | Whether pod owners can sign up. With it off, nothing runs. |
89
- | `agentDataDir` | Where each identity keeps its signing key, credential and door secret; its log lines go to the server's own log. Required whenever sign-up is on. |
105
+ | `agentDataDir` | Where each identity keeps the file naming its pod; its log lines go to the server's own log. Required whenever sign-up is on. |
90
106
  | `agentUiPath` | Where the owner's pages live on the pod's origin. `/fedipod/` by default; empty serves no pages. |
91
107
  | `agentRegistryContainer` | The internal container holding the sign-up rows. |
92
108
  | `runPage` | The HTML served at `/run`: the page where a pod owner opts in or out. The package's own `web/front/run.html` is served unless you set this. |
@@ -149,7 +165,7 @@ owner's door, with that identity's own door secret:
149
165
 
150
166
  ```
151
167
  curl -X POST https://mei.example.org/fedipod/config \
152
- -H 'x-dk-token: THE_DOOR_SECRET_FROM_door-secret.json' -H 'content-type: application/json' \
168
+ -H 'x-dk-token: THE_DOOR_SECRET_FROM_THE_OPT_IN_REPLY' -H 'content-type: application/json' \
153
169
  -d '{"password":"the one you will type into your phone"}'
154
170
  ```
155
171
 
package/dist/claims.d.ts CHANGED
@@ -2,6 +2,13 @@ export declare function claims(input: {
2
2
  host?: string;
3
3
  pathname: string;
4
4
  }, frontHost: string): boolean;
5
+ /**
6
+ * The inbox container of an identity this server runs, under the root the
7
+ * embedded credential uses (lib/server/embed.mjs). A delivery POSTed here is
8
+ * verified at the door before it is written, so the request is claimed from
9
+ * the LDP handler; every other method on the container is the pod's.
10
+ */
11
+ export declare const INBOX_PATH = "/activitypods-js/ap/inbox/";
5
12
  /**
6
13
  * True when this request belongs to an identity's client surface.
7
14
  * `agentHosts` is keyed by host including port, as the Host header carries it.
@@ -9,4 +16,5 @@ export declare function claims(input: {
9
16
  export declare function agentClaims(input: {
10
17
  host?: string;
11
18
  pathname: string;
19
+ method?: string;
12
20
  }, agentHosts: Set<string>, uiPath?: string): boolean;
package/dist/claims.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // front's apex the gateway answers the fediverse routes; a pod subdomain is a
5
5
  // real Solid pod and is never claimed.
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.INBOX_PATH = void 0;
7
8
  exports.claims = claims;
8
9
  exports.agentClaims = agentClaims;
9
10
  const FRONT_PATHS = new Set(['/', '/signup', '/new-account', '/run', '/roster',
@@ -35,6 +36,13 @@ const AGENT_PATHS = new Set([
35
36
  '/.well-known/oauth-authorization-server',
36
37
  ]);
37
38
  const AGENT_PREFIXES = ['/api/', '/oauth/'];
39
+ /**
40
+ * The inbox container of an identity this server runs, under the root the
41
+ * embedded credential uses (lib/server/embed.mjs). A delivery POSTed here is
42
+ * verified at the door before it is written, so the request is claimed from
43
+ * the LDP handler; every other method on the container is the pod's.
44
+ */
45
+ exports.INBOX_PATH = '/activitypods-js/ap/inbox/';
38
46
  /**
39
47
  * True when this request belongs to an identity's client surface.
40
48
  * `agentHosts` is keyed by host including port, as the Host header carries it.
@@ -47,6 +55,8 @@ function agentClaims(input, agentHosts, uiPath = '/fedipod/') {
47
55
  const { pathname } = input;
48
56
  if (AGENT_PATHS.has(pathname))
49
57
  return true;
58
+ if (pathname === exports.INBOX_PATH)
59
+ return String(input.method ?? '').toUpperCase() === 'POST';
50
60
  if (AGENT_PREFIXES.some((prefix) => pathname.startsWith(prefix)))
51
61
  return true;
52
62
  // The owner's door, when there is one: '' turns the pages off entirely.
package/dist/handler.d.ts CHANGED
@@ -91,6 +91,12 @@ export declare class FediPodServerHandler extends HttpHandler implements Initial
91
91
  initialize(): Promise<void>;
92
92
  /** Stop every identity: timers cleared, state written, lease let go. */
93
93
  finalize(): Promise<void>;
94
+ /**
95
+ * One identity's door secret, in its own pod's state. `agentDataDir` is
96
+ * handed in as the place an identity set up before this kept it, so its
97
+ * owner's existing door link survives the move.
98
+ */
99
+ private doorSecretFor;
94
100
  private startIdentity;
95
101
  /**
96
102
  * When this server is also the door, give a running identity a
@@ -132,6 +138,13 @@ export declare class FediPodServerHandler extends HttpHandler implements Initial
132
138
  }): Promise<Record<string, unknown> & {
133
139
  httpStatus: number;
134
140
  }>;
141
+ /**
142
+ * A delivery to an identity's own inbox, verified here — the server that
143
+ * stores the inbox is the one the request reached, so the signature is
144
+ * checked while its headers exist and the receipt is written beside the
145
+ * activity. The same door code the front runs; nothing is renamed.
146
+ */
147
+ private deliverAtDoor;
135
148
  handle({ request, response }: HttpHandlerInput): Promise<void>;
136
149
  }
137
150
  export {};
package/dist/handler.js CHANGED
@@ -25,9 +25,9 @@ const directory_1 = require("./directory");
25
25
  // Two layouts carry that tree: the published package ships its own copy of
26
26
  // lib/ beside dist/ (prepack puts it there), and a repo checkout reaches the
27
27
  // repo's lib/ three levels up. Prefer the package's own copy when it exists.
28
- const LIB_ROOT = (0, node_fs_1.existsSync)((0, node_path_1.join)(__dirname, '../lib/embed.mjs')) ? '../lib' : '../../../lib';
29
- const FRONT_CORE = `${LIB_ROOT}/front-core.mjs`;
30
- const EMBED = `${LIB_ROOT}/embed.mjs`;
28
+ const LIB_ROOT = (0, node_fs_1.existsSync)((0, node_path_1.join)(__dirname, '../lib/server/embed.mjs')) ? '../lib' : '../../../lib';
29
+ const FRONT_CORE = `${LIB_ROOT}/gateway/front-core.mjs`;
30
+ const EMBED = `${LIB_ROOT}/server/embed.mjs`;
31
31
  const esmImport = new Function('s', 'return import(s)');
32
32
  // The front's pages and the files they load, carried in the same two layouts
33
33
  // as lib/. A missing file is not fatal: the route it feeds answers 404.
@@ -215,6 +215,20 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
215
215
  this.logger.info(`FediPod agent @${identity.handle} stopped`);
216
216
  }));
217
217
  }
218
+ /**
219
+ * One identity's door secret, in its own pod's state. `agentDataDir` is
220
+ * handed in as the place an identity set up before this kept it, so its
221
+ * owner's existing door link survives the move.
222
+ */
223
+ async doorSecretFor(podBase, session, opts = {}) {
224
+ const { ensureDoorSecret } = await esmImport(EMBED);
225
+ return ensureDoorSecret(session ?? (0, store_pod_1.makeStoreSession)(this.args.resourceStore, podBase), podBase, {
226
+ ...opts,
227
+ dataDir: this.args.agentDataDir,
228
+ handle: deriveHandle(podBase),
229
+ log: (message) => { this.logger.info(`@${deriveHandle(podBase)}: ${message}`); },
230
+ });
231
+ }
218
232
  async startIdentity(podBase) {
219
233
  if (this.starting.has(podBase) || this.identities.has(podBase))
220
234
  return;
@@ -224,13 +238,13 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
224
238
  try {
225
239
  for (let attempt = 0; !this.stopping && !this.startCancelled.has(podBase); attempt++) {
226
240
  try {
227
- const { startEmbeddedAgent, ensureDoorSecret } = await esmImport(EMBED);
241
+ const { startEmbeddedAgent } = await esmImport(EMBED);
228
242
  // The secret is in the map BEFORE the surface can exist, so the
229
243
  // gate's resolver never comes up empty — empty would mean gate-off.
230
244
  if (!this.doorSecrets.has(podBase)) {
231
- const door = ensureDoorSecret(this.args.agentDataDir, deriveHandle(podBase));
245
+ const door = await this.doorSecretFor(podBase, session);
232
246
  this.doorSecrets.set(podBase, door.secret);
233
- this.logger.info(`door secret for @${deriveHandle(podBase)} is at ${door.path}`);
247
+ this.logger.info(`door secret for @${deriveHandle(podBase)} is in its pod at ${door.url}`);
234
248
  }
235
249
  const identity = await startEmbeddedAgent({
236
250
  podBase,
@@ -311,7 +325,7 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
311
325
  // starting, and then stop being served once it has.
312
326
  if ((0, claims_1.claims)({ host, pathname }, this.frontHost))
313
327
  return;
314
- if ((0, claims_1.agentClaims)({ host, pathname }, this.agentHosts, this.uiPath))
328
+ if ((0, claims_1.agentClaims)({ host, pathname, method: request.method }, this.agentHosts, this.uiPath))
315
329
  return;
316
330
  throw new Error('not a gateway route'); // reject → CSS's LDP handler takes it
317
331
  }
@@ -344,11 +358,10 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
344
358
  }
345
359
  const base = podBase.endsWith('/') ? podBase : `${podBase}/`;
346
360
  const handle = deriveHandle(base);
347
- const { ensureDoorSecret } = await esmImport(EMBED);
348
361
  // Already running here from an earlier opt-in: proving pod
349
362
  // control again buys a fresh secret, nothing else.
350
363
  if (this.agentHandles.get(handle) === base) {
351
- const door = ensureDoorSecret(this.args.agentDataDir, handle, { rotate: true });
364
+ const door = await this.doorSecretFor(base, undefined, { rotate: true });
352
365
  this.doorSecrets.set(base, door.secret);
353
366
  return { httpStatus: 201, ok: true, handle, host: new URL(base).host.toLowerCase(),
354
367
  doorSecret: door.secret, doorPath: this.uiPath, status: 'rotated' };
@@ -371,10 +384,10 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
371
384
  // until the agent registers its surface.
372
385
  this.agentHosts.add(host);
373
386
  this.agentHandles.set(handle, base);
374
- const door = ensureDoorSecret(this.args.agentDataDir, handle, { rotate: true });
387
+ const door = await this.doorSecretFor(base, undefined, { rotate: true });
375
388
  this.doorSecrets.set(base, door.secret);
376
389
  void this.startIdentity(base);
377
- this.logger.info(`runtime opt-in: @${handle} on ${base} (door secret at ${door.path})`);
390
+ this.logger.info(`runtime opt-in: @${handle} on ${base} (door secret in its pod at ${door.url})`);
378
391
  return { httpStatus: 201, ok: true, handle, host,
379
392
  doorSecret: door.secret, doorPath: this.uiPath, status: 'starting' };
380
393
  }
@@ -400,6 +413,33 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
400
413
  this.logger.info(`runtime opt-out: @${row.handle} on ${base} — the pod serves plain LDP again`);
401
414
  return { httpStatus: 200, ok: true, stopped: Boolean(identity) };
402
415
  }
416
+ /**
417
+ * A delivery to an identity's own inbox, verified here — the server that
418
+ * stores the inbox is the one the request reached, so the signature is
419
+ * checked while its headers exist and the receipt is written beside the
420
+ * activity. The same door code the front runs; nothing is renamed.
421
+ */
422
+ async deliverAtDoor(identity, request, response) {
423
+ const { deliverToInbox } = await esmImport(EMBED);
424
+ let whatwg;
425
+ try {
426
+ // The pod's own origin: the signature covers the path and the Host header, and both are the pod's.
427
+ whatwg = await (0, adapt_1.nodeToWhatwg)(request, new URL(identity.podHome).origin);
428
+ }
429
+ catch (e) {
430
+ const status = e.statusCode === 413 ? 413 : 400;
431
+ response.writeHead(status, { 'content-type': 'application/json' });
432
+ response.end(JSON.stringify({ error: e.message }));
433
+ return;
434
+ }
435
+ const out = await deliverToInbox(identity.agent, whatwg, {
436
+ podPut: (url, body, ct) => this.podPut(url, body, ct),
437
+ gatewayWebId: this.args.gatewayWebId ?? null,
438
+ });
439
+ this.logger.info(`FediPod: delivery for @${identity.handle} at the door — ${out.reason} (${out.status})`);
440
+ response.writeHead(out.status, { 'content-type': 'application/json' });
441
+ response.end(JSON.stringify({ reason: out.reason }));
442
+ }
403
443
  async handle({ request, response }) {
404
444
  const host = String(request.headers.host ?? '').toLowerCase();
405
445
  if (this.agentHosts.has(host)) {
@@ -419,6 +459,11 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
419
459
  response.end(JSON.stringify({ error: 'this identity is still starting' }));
420
460
  return;
421
461
  }
462
+ const pathname = new URL(request.url ?? '/', `https://${host}`).pathname;
463
+ if (pathname === claims_1.INBOX_PATH && String(request.method).toUpperCase() === 'POST') {
464
+ await this.deliverAtDoor(identity, request, response);
465
+ return;
466
+ }
422
467
  await identity.surface.handler(request, response);
423
468
  return;
424
469
  }
@@ -312,6 +312,10 @@
312
312
  "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_finalize",
313
313
  "memberFieldName": "finalize"
314
314
  },
315
+ {
316
+ "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_doorSecretFor",
317
+ "memberFieldName": "doorSecretFor"
318
+ },
315
319
  {
316
320
  "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_startIdentity",
317
321
  "memberFieldName": "startIdentity"
@@ -340,6 +344,10 @@
340
344
  "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_optOutPod",
341
345
  "memberFieldName": "optOutPod"
342
346
  },
347
+ {
348
+ "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_deliverAtDoor",
349
+ "memberFieldName": "deliverAtDoor"
350
+ },
343
351
  {
344
352
  "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_handle",
345
353
  "memberFieldName": "handle"
package/dist/store-pod.js CHANGED
@@ -28,11 +28,25 @@ function headerReader(init) {
28
28
  lower[k.toLowerCase()] = String(v);
29
29
  return (n) => lower[n.toLowerCase()] ?? null;
30
30
  }
31
- // Only Turtle needs asking for: a container's listing is quads until something
32
- // requests a syntax, and listContainer parses Turtle. Everything else is read
33
- // back exactly as it was written.
31
+ // A container's listing is quads until something requests a syntax, and
32
+ // listContainer parses Turtle, so Turtle has to be askable for. It must not be
33
+ // the ONLY thing asked for: the state tree reads its own documents with
34
+ // `text/turtle, application/json;q=0.9, */*;q=0.8`, and a JSON document
35
+ // demanded as Turtle is refused outright — which is every state document an
36
+ // identity has, the moment it starts on a pod that already holds some.
37
+ // With no Turtle in the accept, no preference: read back as written.
34
38
  function preferencesFor(accept) {
35
- return accept?.includes('text/turtle') ? { type: { 'text/turtle': 1 } } : {};
39
+ if (!accept?.includes('text/turtle'))
40
+ return {};
41
+ const type = {};
42
+ for (const part of accept.split(',')) {
43
+ const [media, ...params] = part.trim().split(';');
44
+ if (!media)
45
+ continue;
46
+ const q = params.map((p) => (/^\s*q=([\d.]+)\s*$/u).exec(p)).find(Boolean);
47
+ type[media.trim()] = q ? Number(q[1]) : 1;
48
+ }
49
+ return { type };
36
50
  }
37
51
  function isNotFound(e) {
38
52
  return community_server_1.NotFoundHttpError.isInstance?.(e) === true || e?.statusCode === 404;
@@ -15,8 +15,9 @@
15
15
  // read their own inbox" is served from there, by this agent, to the owner
16
16
  // alone.
17
17
 
18
- import * as social from './social.mjs';
19
- import * as wire from './wire.mjs';
18
+ import * as social from '../core/social.mjs';
19
+ import * as wire from '../core/wire.mjs';
20
+ import { readLenient } from '../core/as2.mjs';
20
21
 
21
22
  const MAX_BODY = 512 * 1024; // same ceiling the inbox drain enforces
22
23
 
@@ -190,9 +191,15 @@ export class C2S {
190
191
  if (!took) return this.send(res, 503, { error: 'another agent is active for this pod — takeover failed, try again' });
191
192
  }
192
193
 
194
+ // Read as JSON-LD, so a client may send its activity with whatever context
195
+ // it likes and still be understood. What is read is the GRAPH: `dispatch`
196
+ // takes decisions from it and the publisher builds the document that is
197
+ // actually posted, so nothing a client sent is republished verbatim and a
198
+ // term it aliased still means what it says.
193
199
  let activity;
194
200
  try {
195
- activity = JSON.parse(await readBody(req));
201
+ const read = await readLenient(await readBody(req));
202
+ activity = read.view ?? read.doc;
196
203
  } catch (e) {
197
204
  return this.send(res, 400, { error: `unreadable body: ${e.message}` });
198
205
  }
@@ -12,8 +12,8 @@
12
12
  import https from 'node:https';
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
- import { rootOf, apRoot } from './home.mjs';
16
- import { certPaths } from './certs.mjs';
15
+ import { rootOf, apRoot } from '../device/home.mjs';
16
+ import { certPaths } from '../device/certs.mjs';
17
17
 
18
18
  /**
19
19
  * The authorities worth offering for a loopback call: this install's, and the
@@ -0,0 +1,264 @@
1
+ // accounts.mjs — the account endpoints: the owner's credentials and profile
2
+ // editor, follow requests, markers, relationships, search and lookup, block
3
+ // and mute, follow and unfollow, an account and its lists and statuses, and
4
+ // the web-push subscription a client login holds.
5
+
6
+ import crypto from 'node:crypto';
7
+ import * as podMedia from '../../pod/media.mjs';
8
+ import * as social from '../../core/social.mjs';
9
+ import { readBody } from './body.mjs';
10
+ import { readMultipart } from './media.mjs';
11
+
12
+ export async function handle(api, ctx) {
13
+ const { req, res, pathname, url, send } = ctx; // eslint-disable-line no-unused-vars
14
+
15
+ if (pathname === '/api/v1/accounts/verify_credentials') {
16
+ const cfg0 = api.store.getConfig() || {};
17
+ return send(200, {
18
+ ...api.selfAccount(),
19
+ // `source` is what the editor fills its inputs from: the raw text it
20
+ // will send back, not the HTML the profile renders.
21
+ source: {
22
+ privacy: 'public', sensitive: false, language: 'en',
23
+ note: cfg0.summary || '',
24
+ fields: (cfg0.fields || []).map(f => ({ name: f.name, value: f.value })),
25
+ },
26
+ });
27
+ }
28
+
29
+ // The profile editor. Everything it can send is carried: the name and bio,
30
+ // both pictures, and the extra fields. An avatar or header arrives as file
31
+ // bytes, so it goes to the pod's media container first and the actor gets
32
+ // the URL — the same path a posted attachment takes.
33
+ if (pathname === '/api/v1/accounts/update_credentials') {
34
+ if (req.method !== 'PATCH' && req.method !== 'POST') return send(405, { error: 'PATCH expected' });
35
+ const ct = String(req.headers['content-type'] || '');
36
+ // readBody already covers JSON and urlencoded; only the file case differs.
37
+ let form = {}, files = {};
38
+ if (ct.includes('multipart/form-data')) ({ fields: form, files } = await readMultipart(req));
39
+ else form = await readBody(req);
40
+
41
+ const cfg = { ...api.store.getConfig() };
42
+ const putImage = async (f) => {
43
+ const ext = (f.filename || '').includes('.')
44
+ ? f.filename.split('.').pop().replace(/[^\w]/g, '') : 'bin';
45
+ const slug = new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex') + '.' + ext;
46
+ const url = api.urls.media + slug;
47
+ await api.agent.publisher.ensureMediaContainer();
48
+ await podMedia.write(api.agent.remote, url, f.data, f.contentType);
49
+ return url;
50
+ };
51
+
52
+ if ('display_name' in form) cfg.name = String(form.display_name).trim() || cfg.handle;
53
+ if ('note' in form) cfg.summary = String(form.note) || undefined;
54
+ if ('locked' in form) cfg.approveJoins = form.locked === 'true' || form.locked === true;
55
+ if (files.avatar?.data?.length) cfg.icon = await putImage(files.avatar);
56
+ if (files.header?.data?.length) cfg.image = await putImage(files.header);
57
+
58
+ // fields_attributes arrives as fields_attributes[0][name] etc. A row with
59
+ // no name is how the editor says "delete this one", so it is dropped.
60
+ const rows = [];
61
+ for (const [k, v] of Object.entries(form)) {
62
+ const m = /^fields_attributes\[(\d+)\]\[(name|value)\]$/.exec(k);
63
+ if (!m) continue;
64
+ (rows[Number(m[1])] ||= {})[m[2]] = String(v);
65
+ }
66
+ if (rows.length) cfg.fields = rows.filter(r => r && r.name?.trim())
67
+ .map(r => ({ name: r.name.trim(), value: (r.value || '').trim() }));
68
+
69
+ api.store.setConfig(cfg);
70
+ Object.assign(api.agent.publisher.config, {
71
+ name: cfg.name, summary: cfg.summary, icon: cfg.icon, image: cfg.image,
72
+ fields: cfg.fields, approveJoins: !!cfg.approveJoins,
73
+ });
74
+ await api.store.flush();
75
+ // publishProfile says whether the world can actually read the actor it
76
+ // just wrote. Discarding that reported success for a save that left the
77
+ // account undiscoverable — the one outcome the caller needed to hear.
78
+ const published = await api.agent.publisher.publishProfile();
79
+ const unreachable = published?.unreachable;
80
+ if (unreachable?.length) {
81
+ api.log(`profile saved but NOT publicly readable: ${unreachable.join(', ')}`);
82
+ }
83
+ api.log(`profile updated from a client: ${Object.keys(form).join(', ') || '(files only)'}`);
84
+ return send(200, api.selfAccount());
85
+ }
86
+
87
+ // Follow requests. The queue, and the two answers to it, have existed since
88
+ // groups did — `agent.store.getRequests()`, `admitRequest`, `refuseRequest`,
89
+ // all driven from the record page — but the facade stubbed the list to `[]`
90
+ // and offered no authorize/reject. So a locked account could see and answer
91
+ // its requests in FediPod's own page and in NO Mastodon client: Phanpy,
92
+ // Tuba and Whalebird all showed nothing waiting.
93
+ if (pathname === '/api/v1/follow_requests' && req.method === 'GET') {
94
+ const limit = Math.min(Number(url.searchParams.get('limit')) || 40, 80);
95
+ return send(200, api.store.getRequests().slice(0, limit)
96
+ .map((r) => api.account(r.actor)));
97
+ }
98
+ const mReq = /^\/api\/v1\/follow_requests\/([a-f0-9]+)\/(authorize|reject)$/.exec(pathname);
99
+ if (mReq && req.method === 'POST') {
100
+ // The client addresses an account by the id it was given for it, which is
101
+ // this store's own hash of the actor URL — the same one every other
102
+ // account route here uses.
103
+ const actorUrl = api.store.urlFor(mReq[1]);
104
+ if (!actorUrl) return send(404, { error: 'Record not found' });
105
+ if (!api.store.getRequests().some((r) => r.actor === actorUrl)) {
106
+ return send(404, { error: 'Record not found' });
107
+ }
108
+ try {
109
+ if (mReq[2] === 'authorize') await social.admitRequest(api.agent, actorUrl);
110
+ else await social.refuseRequest(api.agent, actorUrl);
111
+ } catch (e) { return send(422, { error: e.message }); }
112
+ await api.store.flush();
113
+ return send(200, api.relationship(actorUrl));
114
+ }
115
+
116
+ if (pathname === '/api/v1/markers') {
117
+ if (req.method === 'POST') {
118
+ const body = await readBody(req);
119
+ const markers = api.store.read('masto-markers.json', {});
120
+ for (const [k, v] of Object.entries(body)) {
121
+ const lastId = v?.last_read_id || v;
122
+ if (typeof lastId === 'string') {
123
+ markers[k] = { last_read_id: lastId, version: (markers[k]?.version || 0) + 1, updated_at: new Date().toISOString() };
124
+ }
125
+ }
126
+ api.store.write('masto-markers.json', markers);
127
+ return send(200, markers);
128
+ }
129
+ return send(200, api.store.read('masto-markers.json', {}));
130
+ }
131
+
132
+ if (pathname === '/api/v1/accounts/relationships') {
133
+ const ids = [...url.searchParams.getAll('id[]'), ...url.searchParams.getAll('id')];
134
+ const rels = ids.map(id => api.store.urlFor(id)).filter(Boolean).map(u => api.relationship(u));
135
+ return send(200, rels);
136
+ }
137
+
138
+ if (pathname === '/api/v1/accounts/search') {
139
+ return send(200, await api.accountSearch(url.searchParams.get('q')));
140
+ }
141
+
142
+ if (pathname === '/api/v1/accounts/lookup') {
143
+ const acct = String(url.searchParams.get('acct') || '').replace(/^@/, '');
144
+ const cfg = api.store.getConfig();
145
+ if (acct === cfg?.handle || acct === `${cfg?.handle}@${api.host}`) {
146
+ return send(200, api.selfAccount());
147
+ }
148
+ const hit = Object.entries(api.store.getActors()).find(([u, a]) => {
149
+ try { return `${a.preferredUsername}@${new URL(u).host}` === acct; } catch { return false; }
150
+ });
151
+ return hit ? send(200, api.account(hit[0])) : send(404, { error: 'Record not found' });
152
+ }
153
+
154
+ // Block and mute, from where the trouble is seen. A block also unfollows —
155
+ // intake refuses a blocked author already — and a mute is view-only: their
156
+ // posts stay out of the timelines, nothing federates.
157
+ const mRel = /^\/api\/v1\/accounts\/([a-f0-9]+)\/(block|unblock|mute|unmute)$/.exec(pathname);
158
+ if (mRel && req.method === 'POST') {
159
+ const actorUrl = api.store.urlFor(mRel[1]);
160
+ if (!actorUrl) return send(404, { error: 'Record not found' });
161
+ if (mRel[2] === 'block' || mRel[2] === 'unblock') {
162
+ if (mRel[2] === 'block') await social.blockActor(api.agent, actorUrl);
163
+ else await social.unblockActor(api.agent, actorUrl);
164
+ } else {
165
+ const m = api.store.getMuted();
166
+ if (mRel[2] === 'mute' && !m.actors.includes(actorUrl)) m.actors.push(actorUrl);
167
+ if (mRel[2] === 'unmute') m.actors = m.actors.filter(a => a !== actorUrl);
168
+ api.store.setMuted(m);
169
+ }
170
+ return send(200, api.relationship(actorUrl));
171
+ }
172
+
173
+ const mFollow = /^\/api\/v1\/accounts\/([a-f0-9]+)\/(follow|unfollow)$/.exec(pathname);
174
+ if (mFollow && req.method === 'POST') {
175
+ const actorUrl = api.store.urlFor(mFollow[1]);
176
+ if (!actorUrl) return send(404, { error: 'Record not found' });
177
+ if (mFollow[2] === 'follow') await social.followActor(api.agent, actorUrl);
178
+ else await social.unfollowActor(api.agent, actorUrl).catch(() => {}); // already-gone is fine
179
+ return send(200, api.relationship(actorUrl));
180
+ }
181
+
182
+ const mAccount = /^\/api\/v1\/accounts\/([a-f0-9]+)$/.exec(pathname);
183
+ if (mAccount && req.method === 'GET') {
184
+ const actorUrl = api.store.urlFor(mAccount[1]);
185
+ return actorUrl ? send(200, api.account(actorUrl)) : send(404, { error: 'Record not found' });
186
+ }
187
+
188
+ // Web push: one subscription per client login. The agent pushes payloads
189
+ // to the browser's push service itself, so a closed client still hears.
190
+ if (pathname === '/api/v1/push/subscription') {
191
+ const token = (/^Bearer (.+)$/.exec(req.headers.authorization || '') || [])[1];
192
+ if (!token) return send(401, { error: 'The access token is invalid' });
193
+ // A client that ignores the missing `vapid` and subscribes anyway must
194
+ // not be told it worked — a stored subscription nothing ever pushes to is
195
+ // the same silent nothing the toggle was.
196
+ if (!api.webPush) return send(422, { error: 'this instance does not send web push' });
197
+ if (req.method === 'GET') {
198
+ const sub = api.push.get(token);
199
+ return sub ? send(200, api.push.json(token, sub)) : send(404, { error: 'Record not found' });
200
+ }
201
+ if (req.method === 'POST') {
202
+ const body = await readBody(req);
203
+ const sub = api.push.set(token, {
204
+ endpoint: body.subscription?.endpoint,
205
+ keys: body.subscription?.keys,
206
+ alerts: body.data?.alerts,
207
+ });
208
+ if (!sub) return send(422, { error: 'a https endpoint and p256dh/auth keys are required' });
209
+ return send(200, api.push.json(token, sub));
210
+ }
211
+ if (req.method === 'PUT') {
212
+ const body = await readBody(req);
213
+ const sub = api.push.setAlerts(token, body.data?.alerts);
214
+ return sub ? send(200, api.push.json(token, sub)) : send(404, { error: 'Record not found' });
215
+ }
216
+ if (req.method === 'DELETE') {
217
+ api.push.drop(token);
218
+ return send(200, {});
219
+ }
220
+ }
221
+
222
+ if (/^\/api\/v1\/accounts\/[a-f0-9]+\/featured_tags$/.test(pathname)) return send(200, []);
223
+
224
+ // The counts these back are rendered from the same two arrays (see
225
+ // `account`), so a client that shows a number here can always open it.
226
+ const mAccList = /^\/api\/v1\/accounts\/([a-f0-9]+)\/(following|followers)$/.exec(pathname);
227
+ if (mAccList && req.method === 'GET') {
228
+ const actorUrl = api.store.urlFor(mAccList[1]);
229
+ if (!actorUrl) return send(404, { error: 'Record not found' });
230
+ // Only our own lists are known. A remote actor's collections live on its
231
+ // own server, and opening a profile is not worth a fetch of a stranger's
232
+ // pod — an empty list, not an error, is what the API can honestly say.
233
+ const mine = actorUrl === api.urls?.actor;
234
+ const c = api.store.getContacts();
235
+ // Reversed: the contact arrays append, and page()'s cursors read
236
+ // newest-first — fed as stored, since_id answered with its complement.
237
+ const recs = (!mine ? []
238
+ : mAccList[2] === 'followers' ? c.followers
239
+ : c.following.filter(f => f.accepted)) // pending is not following
240
+ .slice().reverse();
241
+ const { items, headers } = api.page(recs, url,
242
+ { limit: 40, max: 80, idOf: (r) => api.store.idFor(r.actor) });
243
+ return send(200, items.map(r => api.account(r.actor)), headers);
244
+ }
245
+
246
+ // Who among the people I follow also follows THEM — a graph we do not hold,
247
+ // and the shape is an entry per requested account, not a bare list.
248
+ if (pathname === '/api/v1/accounts/familiar_followers') {
249
+ const ids = [...url.searchParams.getAll('id[]'), ...url.searchParams.getAll('id')];
250
+ return send(200, ids.map(id => ({ id, accounts: [] })));
251
+ }
252
+
253
+ const mAccStatuses = /^\/api\/v1\/accounts\/([a-f0-9]+)\/statuses$/.exec(pathname);
254
+ if (mAccStatuses) {
255
+ const actorUrl = api.store.urlFor(mAccStatuses[1]);
256
+ const all = api.store.getStatuses();
257
+ const pinnedOnly = url.searchParams.get('pinned') === 'true';
258
+ const { items, headers } = api.page(
259
+ all.filter(s => s.actor === actorUrl && (!pinnedOnly || s.pinned)), url);
260
+ return send(200, items.map(s => api.statusOrBoost(s, { all })), headers);
261
+ }
262
+
263
+ return false;
264
+ }