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.
@@ -20,6 +20,7 @@ import { handleDelivery } from './gateway-core.mjs';
20
20
  import { readCapped, safeFetch, isLoopbackHost } from '../shared/safefetch.mjs';
21
21
  import * as podRoot from '../pod/root.mjs';
22
22
  import * as podPolicy from '../pod/policy.mjs';
23
+ import { podBaseOfWebId } from '../pod/urls.mjs';
23
24
 
24
25
  // The one WebFinger document, spelled out here rather than imported from
25
26
  // wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
@@ -513,20 +514,31 @@ async function route(request, ctx) {
513
514
  if (!/^https?:\/\/\S+\/$/.test(podBase)) {
514
515
  return j(400, { error: 'podBase must be a URL ending in /' });
515
516
  }
516
- // An identity needs an origin of its own, so only an origin root may opt
517
- // in. Anything deeper would also let one path-pod user claim an ancestor
518
- // of another's pod.
519
- try {
520
- if (new URL(podBase).pathname !== '/') {
521
- return j(403, { error: 'podBase must be a pod origin root, like https://mei.example.org/' });
522
- }
523
- } catch { return j(400, { error: 'podBase is not a URL' }); }
517
+ let podUrl;
518
+ try { podUrl = new URL(podBase); } catch { return j(400, { error: 'podBase is not a URL' }); }
519
+ // A pod may be host-root (its own origin) or on a PATH of a shared host — a
520
+ // suffix pod, which the Server runs at `https://server/aisha/`. What is
521
+ // refused is the gateway's own origin root itself: that is where the
522
+ // gateway lives, not a pod, and admitting it would (with the ownership
523
+ // fallback below) let a co-tenant claim the whole origin.
524
+ if (ctx.frontOrigin) {
525
+ try {
526
+ if (podUrl.origin === new URL(ctx.frontOrigin).origin && podUrl.pathname === '/') {
527
+ return j(403, { error: 'that is this gateway, not a pod — name the pod that holds your data' });
528
+ }
529
+ } catch { /* no usable frontOrigin: the checks below still apply */ }
530
+ }
531
+ if (/(^|\/)\.internal(\/|$)/u.test(podUrl.pathname) || /(^|\/)\.\.(\/|$)/u.test(podUrl.pathname)) {
532
+ return j(403, { error: 'that is not a pod address' });
533
+ }
524
534
  const webid = await verifyPodToken(request, pathname, ctx.verifier);
525
535
  if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
526
- // The pod's own server names its owner when it can. Where it does, that is
527
- // the proof; where it does not, the WebID must at least live under the pod.
536
+ // The pod's own server names its owner when it can, and where it does that
537
+ // is the proof. Where it does not, the WebID must live in EXACTLY this pod
538
+ // — its own pod base equal to podBase, not merely starting with it, which on
539
+ // a path server an ancestor of another's pod would.
528
540
  const owners = await podOwners(podBase, ctx.fetchImpl || fetch);
529
- const proven = owners.length ? owners.includes(webid) : webid.startsWith(podBase);
541
+ const proven = owners.length ? owners.includes(webid) : podBaseOfWebId(webid) === podBase;
530
542
  if (!proven) {
531
543
  return j(403, { error: 'the token proves a different pod than the one you listed' });
532
544
  }
package/lib/pod/root.mjs CHANGED
@@ -66,6 +66,17 @@ export async function podLayout(fetchImpl, providerOrigin, { timeoutMs = OWNER_L
66
66
  return /ns\/pim\/space#Storage|pim:Storage/u.test(body) ? 'path' : null;
67
67
  }
68
68
 
69
+ // Whether a document is there — one unauthenticated GET, 200 or not. The caller
70
+ // names the URL; asked before a second setup, so a pod that already serves an
71
+ // actor at the app's container refuses another rather than growing a duplicate.
72
+ export async function resourceExists(fetchImpl, url, { timeoutMs = OWNER_LOOKUP_MS } = {}) {
73
+ try {
74
+ const res = await fetchImpl(url,
75
+ { headers: { accept: 'application/activity+json' }, signal: AbortSignal.timeout(timeoutMs) });
76
+ return !!res && res.status === 200;
77
+ } catch { return false; }
78
+ }
79
+
69
80
  export async function probeAnswers(podUrl, fetchImpl = fetch) {
70
81
  try {
71
82
  const res = await fetchImpl(podUrl, { method: 'HEAD' });
@@ -47,7 +47,8 @@ const mintSecret = () => crypto.randomBytes(32).toString('base64');
47
47
  */
48
48
  export async function ensureDoorSecret(session, podBase, { rotate = false, dataDir = null, handle = null, log = () => {} } = {}) {
49
49
  const base = podBase.endsWith('/') ? podBase : podBase + '/';
50
- const url = apUrls(base, DEFAULT_ROOT).state + 'door-secret.json';
50
+ // Under the identity's own tree (fedipod/), where the gate reads it back.
51
+ const url = apUrls(base, 'fedipod/').state + 'door-secret.json';
51
52
  const onHost = dataDir && handle ? path.join(dataDir, handle, 'door-secret.json') : null;
52
53
 
53
54
  if (!rotate) {
@@ -136,7 +137,7 @@ function ensureCredential(home, { podBase, webId }) {
136
137
  const rec = {
137
138
  webId,
138
139
  remotePod: podBase.endsWith('/') ? podBase : podBase + '/',
139
- root: 'activitypods-js/',
140
+ root: 'fedipod/',
140
141
  keysMode: 'pod',
141
142
  };
142
143
  writeJsonAtomic(file, rec, { mode: 0o600 });
@@ -275,7 +276,7 @@ export async function startEmbeddedAgent({
275
276
  pollSeconds = null,
276
277
  autoAcceptFollows = true,
277
278
  gateToken = null,
278
- uiPath = '/fedipod/',
279
+ uiPath = '/fp/',
279
280
  }) {
280
281
  const base = podBase.endsWith('/') ? podBase : podBase + '/';
281
282
  const handle = handleFor(base);
@@ -323,7 +324,7 @@ export async function startEmbeddedAgent({
323
324
  throw new Error(`no pod at ${base} yet — its owner profile is not there`);
324
325
  }
325
326
  log(`no identity on ${base} yet — provisioning @${handle}`);
326
- await agent.bootstrap({ handle, name: handle, kind: 'person' });
327
+ await agent.bootstrap({ handle, name: handle, kind: 'person', root: cred.root });
327
328
  if (autoAcceptFollows) {
328
329
  agent.store.setConfig({ ...agent.store.getConfig(), autoAcceptFollows: true });
329
330
  await agent.store.flush();
@@ -342,15 +343,31 @@ export async function startEmbeddedAgent({
342
343
  // speaks, the write API, nodeinfo, and behind the door the admin routes and
343
344
  // the web client. Same code the standalone agent serves, minus the routes
344
345
  // that only mean something to a process of one's own.
346
+ //
347
+ // A pod that lives on a PATH of its host (a suffix pod, e.g.
348
+ // https://server.example/aisha/) shares its origin with the front and with
349
+ // every other suffix pod, so its whole surface answers UNDER that path: the
350
+ // mount is the pod's own pathname, and it is stripped before a route is
351
+ // matched and folded back into every self-URL. A host-root or subdomain pod
352
+ // has an empty mount and everything is exactly as it was.
345
353
  const authorities = new FixedAuthorities(base);
346
354
  agent.authorities = authorities;
355
+ const mount = new URL(base).pathname.replace(/\/+$/u, '');
347
356
  const surface = buildAdminSurface({
348
357
  agent,
349
358
  log,
350
- gate: makeGate(gateToken, { secureCookie: authorities.secure }),
359
+ // The door cookie is named the same for every identity; on a shared origin
360
+ // (suffix pods) it has to be scoped to this identity's own door path so two
361
+ // co-tenants do not overwrite each other's. A host-root/subdomain pod keeps
362
+ // the whole-origin cookie it always had.
363
+ gate: makeGate(gateToken, {
364
+ secureCookie: authorities.secure,
365
+ cookiePath: mount ? mount + uiPath : '/',
366
+ }),
351
367
  allowed: authorities,
352
368
  embedded: true,
353
369
  basePath: uiPath,
370
+ mount,
354
371
  publicOrigin: base,
355
372
  scheme: new URL(base).protocol,
356
373
  });
@@ -399,7 +416,7 @@ export async function startEmbeddedAgent({
399
416
  // podHome and actorUrl are the identity's own locations on the pod. They are
400
417
  // returned rather than rebuilt by the caller so the root name lives here.
401
418
  return {
402
- agent, handle, home, surface, host: authorities.host,
419
+ agent, handle, home, surface, host: authorities.host, mount,
403
420
  podHome: urls.home, actorUrl: urls.actor, inboxUrl: urls.inbox, stop,
404
421
  };
405
422
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod-server",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "The FediPod Server: a full ActivityPub server as a Community Solid Server component.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -55,6 +55,7 @@
55
55
  "build:components": "componentsjs-generator -s src -c dist/components -r fps",
56
56
  "test": "npm run build && node --test test/*.mjs",
57
57
  "test:e2e": "node test/e2e/live-agent.mjs",
58
+ "test:e2e:suffix": "node test/e2e/live-suffix.mjs",
58
59
  "prepublishOnly": "npm test",
59
60
  "prepack": "node scripts/pack-tree.mjs copy",
60
61
  "postpack": "node scripts/pack-tree.mjs clean"
package/run-agent.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // run-agent.mjs — fedipod: a standalone single-actor ActivityPub
2
2
  // agent. The remote pod is a RELAY: it serves the public wire face
3
- // (/activitypods-js/ap/) and buffers inbound mail in a public-append inbox
3
+ // (/fedipod/ap/) and buffers inbound mail in a public-append inbox
4
4
  // while this process is off, and it keeps one private document, the lease,
5
5
  // because a lock only one machine can reach coordinates nothing.
6
6
  //
package/vendor/gate.cjs CHANGED
@@ -85,7 +85,7 @@ function cookieValue(header, name) {
85
85
  // AP_ALLOWED_HOSTS has nothing but this token, so the gate has to be total.
86
86
  // `token` may be a function, resolved per request: an identity's secret can
87
87
  // rotate while the server runs, and the very next request sees the new one.
88
- function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCookie = false } = {}) {
88
+ function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCookie = false, cookiePath = '/' } = {}) {
89
89
  const tokenNow = () => (typeof token === 'function' ? token() : token);
90
90
  // gate(req, res) → true when the gate handled the response (caller stops).
91
91
  function gate(req, res) {
@@ -102,7 +102,10 @@ function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCoo
102
102
  url.searchParams.delete(COOKIE);
103
103
  url.searchParams.delete('dk-bless');
104
104
  res.writeHead(302, {
105
- 'set-cookie': `${COOKIE}=${t}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000`
105
+ // Path scopes the cookie to this identity's own door: on a shared
106
+ // origin (suffix pods) two co-tenants must not clobber each other's,
107
+ // and a whole-origin cookie would. Defaults to '/'.
108
+ 'set-cookie': `${COOKIE}=${t}; Path=${cookiePath}; HttpOnly; SameSite=Strict; Max-Age=31536000`
106
109
  + (secureCookie ? '; Secure' : ''),
107
110
  'location': url.pathname + url.search,
108
111
  });
package/web/app/agent.mjs CHANGED
@@ -189,7 +189,7 @@ export class BrowserAgent {
189
189
  // builds its own urls from `config.root` (publisher.mjs), and a config
190
190
  // without one falls to the Node default — so an account set up elsewhere
191
191
  // and signed into here would keep its state under `fedipod/` while every
192
- // document it published landed under `activitypods-js/`. One root, decided
192
+ // document it published landed under a different root. One root, decided
193
193
  // once, carried by the config everything downstream reads.
194
194
  this.store.setConfig({ ...(this.store.getConfig() || {}), ...cfg, root });
195
195
  config = this.store.getConfig();
@@ -33208,6 +33208,47 @@ async function provisionKey(pod, { stateUrl, keysUrl, envelope }) {
33208
33208
  await pod.putJson(keysUrl, envelope, "application/json");
33209
33209
  }
33210
33210
 
33211
+ // lib/pod/root.mjs
33212
+ var OWNER_LOOKUP_MS = 5e3;
33213
+ var PUBLIC_DOC_MAX_BYTES = 1024 * 1024;
33214
+ async function podLayout(fetchImpl, providerOrigin, { timeoutMs = OWNER_LOOKUP_MS } = {}) {
33215
+ let origin;
33216
+ try {
33217
+ origin = new URL(providerOrigin).origin;
33218
+ } catch {
33219
+ return null;
33220
+ }
33221
+ let res;
33222
+ try {
33223
+ res = await fetchImpl(
33224
+ `${origin}/.well-known/solid`,
33225
+ { headers: { accept: "text/turtle" }, signal: AbortSignal.timeout(timeoutMs) }
33226
+ );
33227
+ } catch {
33228
+ return null;
33229
+ }
33230
+ if (res.status === 501) return "host";
33231
+ if (res.status !== 200) return null;
33232
+ let body = "";
33233
+ try {
33234
+ body = await readCapped(res, 64 * 1024);
33235
+ } catch {
33236
+ return null;
33237
+ }
33238
+ return /ns\/pim\/space#Storage|pim:Storage/u.test(body) ? "path" : null;
33239
+ }
33240
+ async function resourceExists(fetchImpl, url, { timeoutMs = OWNER_LOOKUP_MS } = {}) {
33241
+ try {
33242
+ const res = await fetchImpl(
33243
+ url,
33244
+ { headers: { accept: "application/activity+json" }, signal: AbortSignal.timeout(timeoutMs) }
33245
+ );
33246
+ return !!res && res.status === 200;
33247
+ } catch {
33248
+ return false;
33249
+ }
33250
+ }
33251
+
33211
33252
  // web/app/idb-kv.mjs
33212
33253
  var DB = "fedipod-accounts";
33213
33254
  function open() {
@@ -33310,6 +33351,7 @@ async function signUp(answers, { onStep = () => {
33310
33351
  acct.running("checking your pod");
33311
33352
  const head = await fetch(brought, { method: "HEAD" }).catch(() => null);
33312
33353
  if (!head || head.status >= 400) throw new Error(`the pod at ${brought} did not answer (HTTP ${head?.status || "no response"})`);
33354
+ if (await resourceExists(fetch, actorUrlFor(brought))) throw new Error("The pod already hosts a FediPod account. If you want a second account, put it on a different pod.");
33313
33355
  prog.pod = brought;
33314
33356
  acct.skip("using the pod you brought");
33315
33357
  }
@@ -33450,36 +33492,6 @@ function podBaseOfWebId(webId) {
33450
33492
  return `${u.origin}${dir.endsWith("/") ? dir : dir + "/"}`;
33451
33493
  }
33452
33494
 
33453
- // lib/pod/root.mjs
33454
- var OWNER_LOOKUP_MS = 5e3;
33455
- var PUBLIC_DOC_MAX_BYTES = 1024 * 1024;
33456
- async function podLayout(fetchImpl, providerOrigin, { timeoutMs = OWNER_LOOKUP_MS } = {}) {
33457
- let origin;
33458
- try {
33459
- origin = new URL(providerOrigin).origin;
33460
- } catch {
33461
- return null;
33462
- }
33463
- let res;
33464
- try {
33465
- res = await fetchImpl(
33466
- `${origin}/.well-known/solid`,
33467
- { headers: { accept: "text/turtle" }, signal: AbortSignal.timeout(timeoutMs) }
33468
- );
33469
- } catch {
33470
- return null;
33471
- }
33472
- if (res.status === 501) return "host";
33473
- if (res.status !== 200) return null;
33474
- let body = "";
33475
- try {
33476
- body = await readCapped(res, 64 * 1024);
33477
- } catch {
33478
- return null;
33479
- }
33480
- return /ns\/pim\/space#Storage|pim:Storage/u.test(body) ? "path" : null;
33481
- }
33482
-
33483
33495
  // web/app/oidc-session.mjs
33484
33496
  var DB2 = "fedipod-oidc";
33485
33497
  var STORE = "session";