fedipod-server 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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');
@@ -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,
package/lib/core/wire.mjs CHANGED
@@ -20,7 +20,10 @@ export { webfingerHost } from '../pod/urls.mjs';
20
20
  // contains; the browser build states `fedipod/` on its own configs. A config
21
21
  // that names a root is always believed — this is only the answer for one that
22
22
  // does not.
23
- export const DEFAULT_ROOT = 'activitypods-js/';
23
+ // Every build writes its data into a `fedipod/` container in the pod. This is
24
+ // the answer for a config that names no root; a config that names one is always
25
+ // believed. (`activitypods-js/` was an earlier name, now abandoned.)
26
+ export const DEFAULT_ROOT = 'fedipod/';
24
27
 
25
28
  // The handle the fediverse sees: a fronted identity's name is the front's.
26
29
  export function publicHandle(config) {
@@ -47,7 +47,7 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
47
47
  };
48
48
  const podActorId = () => {
49
49
  const base = cfg.remotePod.endsWith('/') ? cfg.remotePod : `${cfg.remotePod}/`;
50
- const root = cfg.root ? (cfg.root.endsWith('/') ? cfg.root : `${cfg.root}/`) : 'activitypods-js/';
50
+ const root = cfg.root ? (cfg.root.endsWith('/') ? cfg.root : `${cfg.root}/`) : 'fedipod/';
51
51
  return `${base}${root}ap/actor`;
52
52
  };
53
53
  // The reply first, the restart a beat later — same shape as /update.
@@ -76,14 +76,14 @@ const ROUTES = [owner, setup, lifecycle, gateway, social, connections];
76
76
  // a fediverse instance must let strangers reach /api and /oauth, so the gate
77
77
  // guards the operator's door (basePath) instead of the whole surface.
78
78
  export function buildAdminSurface({ agent, gate, allowed, log = console.log,
79
- port = null, handle = null, embedded = false, basePath = '/',
79
+ port = null, handle = null, embedded = false, basePath = '/', mount = '',
80
80
  publicOrigin = null, scheme = null,
81
81
  versionOnDisk = () => localVersion(projectRoot) }) {
82
82
  const json = (res, status, obj) => sendJson(res, status, obj, allowed);
83
- const masto = new MastoApi({ agent, log, allowed, scheme, embedded });
83
+ const masto = new MastoApi({ agent, log, allowed, scheme, embedded, mount });
84
84
  // The spec's own write API (§6), beside the facade. Its bearer fallback is
85
85
  // the facade's token, so the two surfaces share one notion of the operator.
86
- const c2s = new C2S({ agent, log, auth: makeC2sAuth({ agent, masto, log, scheme }) });
86
+ const c2s = new C2S({ agent, log, auth: makeC2sAuth({ agent, masto, log, scheme, mount }) });
87
87
  const streaming = new Streaming({ masto, log, allowed, gate, gateOptional: embedded });
88
88
  // Asked per request, not once here: startAdmin runs before connect, so the
89
89
  // kind is not known yet at mount time.
@@ -104,21 +104,29 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
104
104
  } catch (e) { log(`streaming broadcast: ${e.message}`); }
105
105
  };
106
106
 
107
- // A path as the browser must ask for it: behind the door, prefixed with it.
108
- const atPath = (p_) => (basePath === '/' ? p_ : basePath.slice(0, -1) + p_);
107
+ // A path as the browser must ask for it: under this identity's mount (a
108
+ // suffix pod's own path, or nothing) and behind the door, prefixed with it.
109
+ const atPath = (p_) => mount + (basePath === '/' ? p_ : basePath.slice(0, -1) + p_);
109
110
 
110
111
  // What every route may reach: the agent and the deployment's facts.
111
112
  const ctx = { agent, log, allowed, embedded, port, handle, publicOrigin, versionOnDisk, isGroup, json, setup: setup_ };
112
113
 
113
114
  const handler = async (req, res) => {
114
115
  const url = new URL(req.url, 'http://localhost');
116
+ // A suffix pod's surface answers under its mount (its own path on a shared
117
+ // host). Strip it once, here, so every route below is matched relative to
118
+ // the mount and a host-root/subdomain pod (empty mount) is unchanged. The
119
+ // full path stays on `url`/`req.url` for self-URLs that fold the mount back
120
+ // in themselves (the pagination base, the DPoP htu).
121
+ let p = url.pathname;
122
+ if (mount && (p === mount || p.startsWith(mount + '/'))) p = p.slice(mount.length) || '/';
115
123
  // Mastodon-style: the bearer-gated client API and the OAuth + nodeinfo
116
124
  // routes answer any origin — a browser client is served the way any
117
125
  // instance serves it. CORS headers and the preflight make that work; the
118
126
  // bearer stays the only credential, and the Host check below (which is
119
127
  // what stops DNS rebinding) still runs.
120
- const apiPath = url.pathname.startsWith('/api/') || url.pathname.startsWith('/oauth/')
121
- || url.pathname === '/.well-known/nodeinfo' || url.pathname === '/nodeinfo/2.0';
128
+ const apiPath = p.startsWith('/api/') || p.startsWith('/oauth/')
129
+ || p === '/.well-known/nodeinfo' || p === '/nodeinfo/2.0';
122
130
  if (apiPath) {
123
131
  res.setHeader('access-control-allow-origin', '*');
124
132
  res.setHeader('access-control-expose-headers', 'Link');
@@ -142,11 +150,11 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
142
150
  res.end('forbidden\n');
143
151
  return;
144
152
  }
145
- let p = url.pathname;
146
- // Embedded, the operator's door is one path on the pod's origin. Behind it
147
- // is everything that was the admin server; in front of it are the protocol
148
- // routes, which have to answer strangers because that is what makes the pod
149
- // an instance other software can talk to.
153
+ // Embedded, the operator's door is one path on the pod's origin (under the
154
+ // mount, when there is one). Behind it is everything that was the admin
155
+ // server; in front of it are the protocol routes, which have to answer
156
+ // strangers because that is what makes the pod an instance other software
157
+ // can talk to.
150
158
  let atDoor = !embedded;
151
159
  if (embedded && basePath !== '/'
152
160
  && (p === basePath.slice(0, -1) || p.startsWith(basePath))) {
@@ -171,7 +179,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
171
179
  // above still decides who gets this far.
172
180
  if (p === '/.well-known/oauth-authorization-server') {
173
181
  const scheme = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
174
- return json(res, 200, masto.authorizationServerMetadata(`${scheme}://${req.headers.host}`));
182
+ return json(res, 200, masto.authorizationServerMetadata(`${scheme}://${req.headers.host}${mount}`));
175
183
  }
176
184
  if (atDoor && gate(req, res)) return;
177
185
  if (p === '/api/v1/streaming/health') {
@@ -180,7 +188,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
180
188
  // NodeInfo on the agent origin — clients probe it at login.
181
189
  if (p === '/.well-known/nodeinfo') {
182
190
  return json(res, 200, nodeinfoPointer(
183
- `${req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'}://${req.headers.host}/nodeinfo/2.0`));
191
+ `${req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'}://${req.headers.host}${mount}/nodeinfo/2.0`));
184
192
  }
185
193
  if (p === '/nodeinfo/2.0') {
186
194
  return json(res, 200, nodeinfoDoc({
@@ -206,8 +214,8 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
206
214
  // Our own pages come before the group check: a group is set up in the
207
215
  // browser like anything else, and it has a record to edit. It still
208
216
  // serves no fediverse client — see the 404 two lines down.
209
- const mount = webMount(p);
210
- if (mount) {
217
+ const wmount = webMount(p);
218
+ if (wmount) {
211
219
  // Without the slash a page's own relative <script src> resolves one
212
220
  // level up and 404s — and that is true at any depth, so ask the
213
221
  // filesystem rather than only special-casing the mount itself.
@@ -217,7 +225,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
217
225
  res.end();
218
226
  return;
219
227
  }
220
- return serveWeb(res, p, mount, allowed);
228
+ return serveWeb(res, p, wmount, allowed);
221
229
  }
222
230
  // The bare URL means "show me what this agent wants from me now".
223
231
  // Keyed on the credential FILE, never on configured(): a healthy
@@ -18,7 +18,7 @@ export async function setup() {
18
18
  if (process.stdin.isTTY && !has('cli') && !IDENTITY_FLAGS.some(f => args.includes('--' + f))) {
19
19
  return runBrowserSetup();
20
20
  }
21
- const root = flag('root');
21
+ const root = flag('root') || 'fedipod/'; // new installs default to the fedipod/ container
22
22
  const kind = has('group') ? 'group' : 'person';
23
23
  const approveJoins = has('group') && has('approve-joins');
24
24
  const summary = flag('summary');
@@ -48,6 +48,14 @@ if (!newAccount && !pod) {
48
48
  }
49
49
  }
50
50
  if (!newAccount && !pod) { console.error('no pod given'); process.exit(2); }
51
+ if (!newAccount) {
52
+ const { resourceExists } = await import(new URL('../../../../lib/pod/root.mjs', import.meta.url));
53
+ const { apUrls, DEFAULT_ROOT: DR } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
54
+ if (await resourceExists(fetch, apUrls(pod, DR).actor)) {
55
+ console.error('The pod already hosts a FediPod account. If you want a second account, put it on a different pod.');
56
+ process.exit(2);
57
+ }
58
+ }
51
59
 
52
60
  const issuer = flag('issuer') || await ask('Solid identity provider', 'https://solidcommunity.net');
53
61
  // Before the password is asked for, let alone sent. The issuer is where it
@@ -19,6 +19,7 @@ import { hashPassword } from '../client/masto/index.mjs';
19
19
  import { webfingerHost, apUrls, DEFAULT_ROOT } from '../core/wire.mjs';
20
20
  import { rootOf, recordLastUsed, writeJsonAtomic } from './home.mjs';
21
21
  import { insecureUrlReason } from '../shared/safefetch.mjs';
22
+ import { resourceExists } from '../pod/root.mjs';
22
23
  import { CURRENT_LAYOUT, isCurrent } from './migrate.mjs';
23
24
 
24
25
  const SOLID = $rdf.Namespace('http://www.w3.org/ns/solid/terms#');
@@ -222,6 +223,8 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
222
223
  gateway = null, shape = 'pod', gatewayOrigin = 'https://fedipod.net',
223
224
  } = answers;
224
225
  let { pod, root } = answers;
226
+ if (!root) root = 'fedipod/'; // new installs default to the fedipod/ container; a
227
+ // resuming run overwrites this with the credential's own root below.
225
228
  let accountWebId = null; // what createAccountWithPod reported, when it ran
226
229
  // The private half always starts here, beside the credential and the keys —
227
230
  // not on the pod. Every activity you receive would otherwise cost the pod
@@ -263,6 +266,9 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
263
266
  // reachable, and no silent 401 later on a pod whose profile is empty.
264
267
  const usable = await checkPod(pod);
265
268
  if (!usable.ok) throw new Error(usable.error);
269
+ if (await (deps.resourceExists || resourceExists)(deps.fetch || fetch, apUrls(pod, root || DEFAULT_ROOT).actor)) {
270
+ throw new Error('The pod already hosts a FediPod account. If you want a second account, put it on a different pod.');
271
+ }
266
272
  skip('account', 'using the pod you already have');
267
273
  }
268
274