fedipod-server 0.16.0 → 0.18.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 (55) hide show
  1. package/README.md +37 -15
  2. package/dist/claims.d.ts +10 -11
  3. package/dist/claims.js +19 -17
  4. package/dist/directory.d.ts +11 -3
  5. package/dist/directory.js +28 -14
  6. package/dist/handler.d.ts +30 -7
  7. package/dist/handler.js +133 -42
  8. package/dist/handler.jsonld +14 -6
  9. package/dist/streaming-handler.js +7 -2
  10. package/lib/client/c2s.mjs +95 -61
  11. package/lib/client/masto/index.mjs +7 -1
  12. package/lib/client/masto/timelines.mjs +1 -1
  13. package/lib/client/oidc-auth.mjs +5 -3
  14. package/lib/core/contexts/anno.json +126 -0
  15. package/lib/core/contexts/index.mjs +4 -0
  16. package/lib/core/contexts/map.json +2 -1
  17. package/lib/core/intake/index.mjs +32 -5
  18. package/lib/core/publisher/index.mjs +15 -1
  19. package/lib/core/publisher/notes.mjs +84 -2
  20. package/lib/core/publisher/questions.mjs +1 -0
  21. package/lib/core/publisher/restore.mjs +27 -2
  22. package/lib/core/social.mjs +1 -0
  23. package/lib/core/store.mjs +4 -0
  24. package/lib/core/wire.mjs +20 -13
  25. package/lib/device/admin/routes/gateway.mjs +1 -1
  26. package/lib/device/admin/surface.mjs +25 -17
  27. package/lib/device/cli/commands/setup.mjs +9 -1
  28. package/lib/device/setup.mjs +6 -0
  29. package/lib/gateway/front-core.mjs +66 -12
  30. package/lib/gateway/gateway-core.mjs +40 -0
  31. package/lib/pod/actor.mjs +2 -2
  32. package/lib/pod/root.mjs +11 -0
  33. package/lib/pod/transport.mjs +9 -3
  34. package/lib/server/embed.mjs +23 -6
  35. package/package.json +2 -1
  36. package/run-agent.mjs +10 -1
  37. package/vendor/gate.cjs +5 -2
  38. package/web/admin/index.html +2 -0
  39. package/web/admin/upkeep.js +9 -1
  40. package/web/app/README.md +1 -1
  41. package/web/app/agent.mjs +11 -1
  42. package/web/app/boot.mjs +49 -10
  43. package/web/app/dist/boot.js +89 -37
  44. package/web/app/dist/boot.js.map +3 -3
  45. package/web/app/dist/sw.js +1326 -662
  46. package/web/app/dist/sw.js.map +4 -4
  47. package/web/app/index.html +16 -6
  48. package/web/app/signup.mjs +3 -1
  49. package/web/app/site/admin/index.html +2 -0
  50. package/web/app/site/admin/upkeep.js +9 -1
  51. package/web/app/site/boot.js +89 -37
  52. package/web/app/site/index.html +16 -6
  53. package/web/app/site/sw.js +1326 -662
  54. package/web/front/#new-account.html# +0 -43
  55. package/web/front/new-account.html~ +0 -50
package/dist/handler.js CHANGED
@@ -17,6 +17,14 @@ const adapt_1 = require("./adapt");
17
17
  const store_css_1 = require("./store-css");
18
18
  const store_pod_1 = require("./store-pod");
19
19
  const directory_1 = require("./directory");
20
+ /** The pod's path as a mount prefix: `''` for a host root, else `/aisha`. */
21
+ function mountOf(podBase) {
22
+ return new URL(podBase).pathname.replace(/\/+$/u, '');
23
+ }
24
+ /** Whether `a` is `b` or an ancestor path of `b` (both mount-shaped, no trailing slash). */
25
+ function pathContains(a, b) {
26
+ return a === b || b.startsWith(a + '/') || a === '';
27
+ }
20
28
  // The JS front-core is FediPod's own ESM tree, reached at runtime. A real
21
29
  // dynamic import() built via Function keeps tsc from downleveling it to
22
30
  // require() — which cannot load an ESM module with top-level await under a
@@ -54,7 +62,7 @@ function deriveHandle(podBase) {
54
62
  function normalizeUiPath(raw) {
55
63
  if (raw === '')
56
64
  return '';
57
- const path = raw ?? '/fedipod/';
65
+ const path = raw ?? '/fp/';
58
66
  return `/${path.replace(/^\/+|\/+$/gu, '')}/`;
59
67
  }
60
68
  class FediPodServerHandler extends community_server_1.HttpHandler {
@@ -63,12 +71,17 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
63
71
  dir;
64
72
  podPut;
65
73
  logger = (0, community_server_1.getLoggerFor)(this);
66
- agentHosts = new Set();
74
+ // Every pod whose routes this server claims, keyed by pod base. A claim
75
+ // records the host it answers on and the mount — the pod's own path, `''` for
76
+ // a host-root or subdomain pod, `/aisha` for a suffix pod on `server/aisha/`.
77
+ // A suffix pod shares its host (often the front's own) with others, so a
78
+ // request is matched to an identity by host AND mount, never host alone.
79
+ claimed = new Map(); // pod base → claim
67
80
  agentHandles = new Map(); // handle → pod base
68
81
  frontHost;
69
82
  uiPath;
70
83
  identities = new Map();
71
- surfaces = new Map();
84
+ surfaces = new Map(); // pod base → running identity
72
85
  registry;
73
86
  doorSecrets = new Map(); // pod base → its door secret
74
87
  starting = new Set();
@@ -100,32 +113,81 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
100
113
  : null;
101
114
  }
102
115
  /**
103
- * Whether this pod may become an identity here: a real URL, an origin of its
104
- * own, not the front's host, and a handle no other identity already uses —
105
- * two pods must never share <agentDataDir>/<handle>/.
116
+ * Whether this pod may become an identity here, and the claim it earns: a
117
+ * real URL, a place no other identity already sits, and a handle no other
118
+ * identity uses — two pods must never share <agentDataDir>/<handle>/.
119
+ *
120
+ * A HOST-ROOT or subdomain pod needs an origin of its own, and it may not be
121
+ * the front's host: the whole surface answers at the origin root, so two of
122
+ * them, or one sharing the front, would collide. A SUFFIX pod lives on a path
123
+ * (`server/aisha/`), so it may share its host — with the front and with other
124
+ * suffix pods — provided no claim already contains or nests under its path.
106
125
  */
107
- validateAgentHost(podBase) {
126
+ validateAgentPod(podBase) {
108
127
  let host;
128
+ let mount;
109
129
  try {
110
- host = new URL(podBase).host.toLowerCase();
130
+ const u = new URL(podBase);
131
+ host = u.host.toLowerCase();
132
+ mount = mountOf(podBase);
111
133
  }
112
134
  catch {
113
135
  throw new Error(`not a pod URL: ${podBase}`);
114
136
  }
115
- // The Mastodon client API is rooted at an origin, so two identities
116
- // cannot share one and an identity cannot share the front's origin.
117
- if (this.agentHosts.has(host)) {
118
- throw new Error(`the host ${host} already carries an identity — an identity needs an origin of its own`);
137
+ if (mount === '') {
138
+ // A pod at the root of its host: it owns the whole origin, so it cannot
139
+ // share it with the front or with any other claim.
140
+ if (host.split(':')[0] === String(this.frontHost).toLowerCase()) {
141
+ throw new Error(`${podBase} is on the front's own host — give the identity its own origin, or host it on a path`);
142
+ }
143
+ for (const c of this.claimed.values()) {
144
+ if (c.host === host) {
145
+ throw new Error(`the host ${host} already carries an identity — a host-root identity needs an origin of its own`);
146
+ }
147
+ }
119
148
  }
120
- if (host.split(':')[0] === String(this.frontHost).toLowerCase()) {
121
- throw new Error(`${podBase} is on the front's own host give the identity its own origin`);
149
+ else {
150
+ // A pod on a path: it owns only its subtree. Refuse anything that already
151
+ // contains it or that it would contain, so no identity can answer under
152
+ // another's path (and none straddles the front's own routes underneath).
153
+ for (const c of this.claimed.values()) {
154
+ if (c.host !== host)
155
+ continue;
156
+ if (pathContains(c.mount, mount) || pathContains(mount, c.mount)) {
157
+ throw new Error(`${podBase} nests with the identity already at ${c.podBase} — a path pod owns only its own subtree`);
158
+ }
159
+ }
122
160
  }
123
161
  const handle = deriveHandle(podBase);
124
162
  const holder = this.agentHandles.get(handle);
125
163
  if (holder && holder !== podBase) {
126
164
  throw new Error(`the name ${handle} already belongs to ${holder} — two identities cannot share it`);
127
165
  }
128
- return host;
166
+ return { host, mount, podBase, handle };
167
+ }
168
+ /**
169
+ * The claim a request belongs to, or null. A request matches when its host is
170
+ * the claim's host and its path is at or under the claim's mount; the deepest
171
+ * mount wins, so a suffix pod's own routes are never swallowed by a shallower
172
+ * claim on the same host.
173
+ */
174
+ resolveClaim(host, pathname) {
175
+ let best = null;
176
+ for (const c of this.claimed.values()) {
177
+ if (c.host !== host)
178
+ continue;
179
+ if (c.mount === '' || pathname === c.mount || pathname.startsWith(c.mount + '/')) {
180
+ if (!best || c.mount.length > best.mount.length)
181
+ best = c;
182
+ }
183
+ }
184
+ return best;
185
+ }
186
+ /** A request path relative to a mount: `/aisha/ap/actor` under `/aisha` → `/ap/actor`. */
187
+ stripMount(pathname, mount) {
188
+ if (!mount)
189
+ return pathname;
190
+ return pathname.slice(mount.length) || '/';
129
191
  }
130
192
  /**
131
193
  * Start an agent for each opted-in pod. Runs before the server listens, so
@@ -164,21 +226,22 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
164
226
  // persist, and the next start recovers them.
165
227
  const pods = [];
166
228
  try {
167
- const hosts = await this.registry.listHosts();
168
- for (const host of hosts) {
169
- const row = await this.registry.get(host);
229
+ const keys = await this.registry.listKeys();
230
+ for (const key of keys) {
231
+ const row = await this.registry.get(key);
170
232
  if (!row)
171
233
  continue;
172
- if (this.agentHosts.has(row.host) || this.identities.has(row.podBase))
234
+ if (this.claimed.has(row.podBase) || this.identities.has(row.podBase))
173
235
  continue; // already claimed
236
+ let claim;
174
237
  try {
175
- this.validateAgentHost(row.podBase);
238
+ claim = this.validateAgentPod(row.podBase);
176
239
  }
177
240
  catch (e) {
178
241
  this.logger.error(`opted-in pod ${row.podBase} no longer valid: ${e.message}`);
179
242
  continue;
180
243
  }
181
- this.agentHosts.add(row.host);
244
+ this.claimed.set(row.podBase, claim);
182
245
  this.agentHandles.set(row.handle, row.podBase);
183
246
  pods.push(row.podBase);
184
247
  }
@@ -266,10 +329,15 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
266
329
  return;
267
330
  }
268
331
  this.identities.set(podBase, identity);
269
- this.surfaces.set(identity.host, identity);
332
+ this.surfaces.set(podBase, identity);
270
333
  this.logger.info(`FediPod agent @${identity.handle} running on ${podBase}`);
271
- if (this.args.agentAutoFront)
334
+ // A suffix pod cannot answer WebFinger for itself — its host root is
335
+ // the front's — so it is followable ONLY through the door's apex
336
+ // dispatch. Front it whether or not auto-front is on: the row is the
337
+ // whole of what makes @handle@host resolve to it.
338
+ if (this.args.agentAutoFront || this.claimed.get(podBase)?.mount) {
272
339
  await this.frontIdentity(podBase, identity);
340
+ }
273
341
  return;
274
342
  }
275
343
  catch (e) {
@@ -318,14 +386,20 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
318
386
  }
319
387
  }
320
388
  async canHandle({ request }) {
321
- const host = request.headers.host;
389
+ const host = String(request.headers.host ?? '').toLowerCase();
322
390
  const pathname = new URL(request.url ?? '/', `https://${host}`).pathname;
323
- // Claimed from the opt-in roster, never from what is running: a pod resource
324
- // must not be served by CSS for the seconds before an identity finishes
325
- // starting, and then stop being served once it has.
391
+ // The front's own routes win first, so a suffix pod can never shadow the
392
+ // door's dispatch, its WebFinger or its API even where its mount would
393
+ // otherwise contain that path.
326
394
  if ((0, claims_1.claims)({ host, pathname }, this.frontHost))
327
395
  return;
328
- if ((0, claims_1.agentClaims)({ host, pathname, method: request.method }, this.agentHosts, this.uiPath))
396
+ // Claimed from the opt-in roster, never from what is running: a pod resource
397
+ // must not be served by CSS for the seconds before an identity finishes
398
+ // starting, and then stop being served once it has. The path is matched
399
+ // relative to the claim's mount, so a suffix pod's `/aisha/ap/actor` is
400
+ // judged as `/ap/actor`.
401
+ const c = this.resolveClaim(host, pathname);
402
+ if (c && (0, claims_1.agentClaims)({ pathname: this.stripMount(pathname, c.mount), method: request.method }, this.uiPath))
329
403
  return;
330
404
  throw new Error('not a gateway route'); // reject → CSS's LDP handler takes it
331
405
  }
@@ -340,9 +414,17 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
340
414
  const cluster = this.args.clusterManager;
341
415
  return !cluster || cluster.isSingleThreaded() || cluster.isPrimary();
342
416
  }
343
- /** The running identity answering on a host, if it has finished starting. */
344
- surfaceFor(host) {
345
- return this.surfaces.get(String(host ?? '').toLowerCase());
417
+ /**
418
+ * The identity a request belongs to (matched by host and mount) and the
419
+ * request path relative to that identity's mount, or null. The identity is
420
+ * undefined when the pod is claimed but still starting. Used by the streaming
421
+ * upgrade, which has only the request to go on.
422
+ */
423
+ matchIdentity(host, pathname = '/') {
424
+ const c = this.resolveClaim(String(host ?? '').toLowerCase(), pathname);
425
+ if (!c)
426
+ return null;
427
+ return { identity: this.surfaces.get(c.podBase), rel: this.stripMount(pathname, c.mount) };
346
428
  }
347
429
  /**
348
430
  * A pod owner, already proven to control podBase, asks this server to run
@@ -366,13 +448,14 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
366
448
  return { httpStatus: 201, ok: true, handle, host: new URL(base).host.toLowerCase(),
367
449
  doorSecret: door.secret, doorPath: this.uiPath, status: 'rotated' };
368
450
  }
369
- let host;
451
+ let claim;
370
452
  try {
371
- host = this.validateAgentHost(base);
453
+ claim = this.validateAgentPod(base);
372
454
  }
373
455
  catch (e) {
374
456
  return { httpStatus: 409, error: e.message };
375
457
  }
458
+ const { host } = claim;
376
459
  try {
377
460
  await this.registry.add({ podBase: base, handle, host, webId, optedInAt: new Date().toISOString() });
378
461
  }
@@ -382,7 +465,7 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
382
465
  }
383
466
  // From this instant the pod's identity routes answer 503 instead of LDP,
384
467
  // until the agent registers its surface.
385
- this.agentHosts.add(host);
468
+ this.claimed.set(base, claim);
386
469
  this.agentHandles.set(handle, base);
387
470
  const door = await this.doorSecretFor(base, undefined, { rotate: true });
388
471
  this.doorSecrets.set(base, door.secret);
@@ -397,19 +480,20 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
397
480
  return { httpStatus: 501, error: 'this server does not offer runtime opt-in' };
398
481
  const base = podBase.endsWith('/') ? podBase : `${podBase}/`;
399
482
  const host = new URL(base).host.toLowerCase();
400
- const row = await this.registry.get(host);
483
+ const key = (0, directory_1.agentKey)(host, base);
484
+ const row = await this.registry.get(key);
401
485
  if (!row || row.podBase !== base)
402
486
  return { httpStatus: 404, error: 'this pod has not opted in' };
403
- this.agentHosts.delete(host); // routes fall to LDP now
487
+ this.claimed.delete(base); // routes fall to LDP now
404
488
  this.startCancelled.add(base); // a pending start stands down
405
489
  const identity = this.identities.get(base);
406
490
  this.identities.delete(base);
407
- this.surfaces.delete(host);
491
+ this.surfaces.delete(base);
408
492
  this.agentHandles.delete(row.handle);
409
493
  this.doorSecrets.delete(base);
410
494
  if (identity)
411
495
  await identity.stop();
412
- await this.registry.remove(host);
496
+ await this.registry.remove(key);
413
497
  this.logger.info(`runtime opt-out: @${row.handle} on ${base} — the pod serves plain LDP again`);
414
498
  return { httpStatus: 200, ok: true, stopped: Boolean(identity) };
415
499
  }
@@ -442,8 +526,13 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
442
526
  }
443
527
  async handle({ request, response }) {
444
528
  const host = String(request.headers.host ?? '').toLowerCase();
445
- if (this.agentHosts.has(host)) {
446
- const identity = this.surfaces.get(host);
529
+ const pathname = new URL(request.url ?? '/', `https://${host}`).pathname;
530
+ // The front's own routes win first — its dispatch, WebFinger and API sit at
531
+ // the apex above every suffix pod — so a claim is consulted only where the
532
+ // front does not answer.
533
+ const claimed = (0, claims_1.claims)({ host, pathname }, this.frontHost) ? null : this.resolveClaim(host, pathname);
534
+ if (claimed) {
535
+ const identity = this.surfaces.get(claimed.podBase);
447
536
  if (!identity) {
448
537
  // Claimed, but nothing here can answer for it. Saying which of the two
449
538
  // reasons it is beats letting the pod answer for a route that is not
@@ -459,8 +548,10 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
459
548
  response.end(JSON.stringify({ error: 'this identity is still starting' }));
460
549
  return;
461
550
  }
462
- const pathname = new URL(request.url ?? '/', `https://${host}`).pathname;
463
- if (pathname === claims_1.INBOX_PATH && String(request.method).toUpperCase() === 'POST') {
551
+ // This identity's own inbox path — <mount>/<root>/ap/inbox/, from its
552
+ // actor, so it already carries the mount for a suffix pod.
553
+ const inboxPath = new URL(identity.actorUrl).pathname.replace(/ap\/actor$/u, 'ap/inbox/');
554
+ if (pathname === inboxPath && String(request.method).toUpperCase() === 'POST') {
464
555
  await this.deliverAtDoor(identity, request, response);
465
556
  return;
466
557
  }
@@ -249,8 +249,8 @@
249
249
  "memberFieldName": "logger"
250
250
  },
251
251
  {
252
- "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_agentHosts",
253
- "memberFieldName": "agentHosts"
252
+ "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_claimed",
253
+ "memberFieldName": "claimed"
254
254
  },
255
255
  {
256
256
  "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_agentHandles",
@@ -301,8 +301,16 @@
301
301
  "memberFieldName": "constructor"
302
302
  },
303
303
  {
304
- "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_validateAgentHost",
305
- "memberFieldName": "validateAgentHost"
304
+ "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_validateAgentPod",
305
+ "memberFieldName": "validateAgentPod"
306
+ },
307
+ {
308
+ "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_resolveClaim",
309
+ "memberFieldName": "resolveClaim"
310
+ },
311
+ {
312
+ "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_stripMount",
313
+ "memberFieldName": "stripMount"
306
314
  },
307
315
  {
308
316
  "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_initialize",
@@ -333,8 +341,8 @@
333
341
  "memberFieldName": "runsIdentities"
334
342
  },
335
343
  {
336
- "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_surfaceFor",
337
- "memberFieldName": "surfaceFor"
344
+ "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_matchIdentity",
345
+ "memberFieldName": "matchIdentity"
338
346
  },
339
347
  {
340
348
  "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_optInPod",
@@ -23,12 +23,17 @@ class FediPodStreamingHandler extends community_server_1.WebSocketHandler {
23
23
  async canHandle({ upgradeRequest }) {
24
24
  const host = upgradeRequest.headers.host;
25
25
  const { pathname } = new URL(upgradeRequest.url ?? '/', `http://${host}`);
26
- if (!pathname.startsWith(STREAMING_PATH) || !this.server.surfaceFor(host)) {
26
+ // Matched relative to the identity's mount, so a suffix pod's own streaming
27
+ // socket at `/aisha/api/v1/streaming` is recognised as `/api/v1/streaming`.
28
+ const match = this.server.matchIdentity(host, pathname);
29
+ if (!match || !match.rel.startsWith(STREAMING_PATH)) {
27
30
  throw new Error('not a FediPod streaming socket');
28
31
  }
29
32
  }
30
33
  async handle({ webSocket, upgradeRequest }) {
31
- const identity = this.server.surfaceFor(upgradeRequest.headers.host);
34
+ const host = upgradeRequest.headers.host;
35
+ const { pathname } = new URL(upgradeRequest.url ?? '/', `http://${host}`);
36
+ const identity = this.server.matchIdentity(host, pathname)?.identity;
32
37
  const streaming = identity?.surface?.streaming;
33
38
  if (!streaming) {
34
39
  webSocket.close(1011, 'identity not running');