fedipod-server 0.15.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.
Files changed (43) 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/masto/index.mjs +7 -1
  11. package/lib/client/masto/timelines.mjs +1 -1
  12. package/lib/client/oidc-auth.mjs +5 -3
  13. package/lib/core/publisher/index.mjs +1 -1
  14. package/lib/core/wire.mjs +4 -1
  15. package/lib/device/admin/routes/gateway.mjs +1 -1
  16. package/lib/device/admin/routes/lifecycle.mjs +1 -1
  17. package/lib/device/admin/routes/setup.mjs +20 -1
  18. package/lib/device/admin/surface.mjs +25 -17
  19. package/lib/device/cli/commands/setup.mjs +53 -21
  20. package/lib/device/setup.mjs +105 -25
  21. package/lib/gateway/front-core.mjs +23 -11
  22. package/lib/pod/root.mjs +12 -1
  23. package/lib/server/embed.mjs +23 -6
  24. package/package.json +2 -1
  25. package/run-agent.mjs +1 -1
  26. package/vendor/gate.cjs +5 -2
  27. package/web/admin/setup/index.html +25 -2
  28. package/web/admin/setup/setup.js +30 -1
  29. package/web/app/README.md +2 -2
  30. package/web/app/admin-facade.mjs +1 -1
  31. package/web/app/agent.mjs +3 -3
  32. package/web/app/boot.mjs +2 -2
  33. package/web/app/dist/boot.js +43 -31
  34. package/web/app/dist/boot.js.map +3 -3
  35. package/web/app/dist/sw.js +4 -4
  36. package/web/app/dist/sw.js.map +2 -2
  37. package/web/app/index.html +1 -1
  38. package/web/app/signup.mjs +5 -3
  39. package/web/app/site/admin/setup/index.html +25 -2
  40. package/web/app/site/admin/setup/setup.js +30 -1
  41. package/web/app/site/boot.js +1 -1
  42. package/web/app/site/index.html +1 -1
  43. package/web/app/site/sw.js +3 -3
@@ -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
@@ -79,9 +87,26 @@ const name = flag('name') || await ask('display name (shown above your address)'
79
87
  // A handle resolves through <host>/.well-known/webfinger, so it only works
80
88
  // when the pod owns the root of its host. Whether a NEW pod gets its own
81
89
  // subdomain is the server's call, so promise nothing here we cannot keep.
82
- const { webfingerHost } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
90
+ const { webfingerHost, apUrls, DEFAULT_ROOT } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
83
91
  const issuerHost = new URL(issuer).host;
84
92
  const wfHost = newAccount ? null : webfingerHost(pod);
93
+
94
+ // Where the address lives: on the pod (default), or at a gateway. A pod on a
95
+ // suffix-based host cannot answer WebFinger for a handle, so its address
96
+ // is at the gateway whatever was asked. `--address front` takes a gateway
97
+ // address for a host-root pod too; `--gateway <origin>` names the gateway.
98
+ const addressShape = String(flag('address') || 'pod').toLowerCase();
99
+ if (addressShape !== 'pod' && addressShape !== 'front') { console.error('--address must be pod or front'); process.exit(2); }
100
+ const gatewayOrigin = flag('gateway') || 'https://fedipod.net';
101
+ { const badGw = insecureUrlReason(gatewayOrigin, 'gateway address'); if (badGw) { console.error(badGw); process.exit(2); } }
102
+ const gatewayHost = new URL(gatewayOrigin).host;
103
+ // For an EXISTING pod the shape is known now; for a NEW pod the path case is
104
+ // decided after the pod is made, below.
105
+ let fronted = (!newAccount && !wfHost) || addressShape === 'front';
106
+ if (fronted && kind === 'group') {
107
+ console.error('a group needs a pod at its own host — a gateway address for a group is not supported yet.');
108
+ process.exit(2);
109
+ }
85
110
  // A person warned about an unresolvable handle is the one who suffers, so a
86
111
  // warning is their call to accept. Nobody could ever find this group, and the
87
112
  // people it would fail are not the operator reading the warning.
@@ -93,20 +118,16 @@ if (kind === 'group' && !newAccount && !wfHost) {
93
118
  }
94
119
  console.log(kind === 'group' ? '\nThe group will be:\n' : '\nYou will be:\n');
95
120
  console.log(` ${name}`);
96
- if (wfHost) {
121
+ if (fronted) {
122
+ console.log(` @${handle}@${gatewayHost}\n`);
123
+ console.log(`— your address lives at ${gatewayHost}; your posts, key and data stay on your pod.\n`);
124
+ } else if (wfHost) {
97
125
  console.log(` @${handle}@${wfHost}\n`);
98
126
  } else if (newAccount) {
99
127
  console.log(` @${handle}@${podName}.${issuerHost}\n`);
100
- console.log(`— provided ${issuerHost} gives each pod its own subdomain. Some servers put`);
101
- console.log(`pods at ${issuerHost}/${podName}/ instead, and a pod sharing a host cannot`);
102
- console.log('answer WebFinger for an address. Setup checks which you got and says so');
103
- console.log('before publishing anything.\n');
104
- } else {
105
- console.log(` @${handle}@${new URL(pod).host} — WILL NOT RESOLVE\n`);
106
- console.log(`This pod is ${pod} — a path on ${new URL(pod).host}, not the root of its own`);
107
- console.log('host. WebFinger is answered only at a host root, which this pod cannot');
108
- console.log('write to, so other servers will not find you. Posting and reading still');
109
- console.log('work; being discovered does not.\n');
128
+ console.log(`— provided ${issuerHost} gives each pod its own subdomain. A server that puts`);
129
+ console.log(`pods at ${issuerHost}/${podName}/ instead cannot answer WebFinger for an address,`);
130
+ console.log(`so setup takes an address at ${gatewayHost} for you and says so.\n`);
110
131
  }
111
132
  console.log('The display name can be changed later; the handle and pod cannot.');
112
133
  const go = await ask(newAccount
@@ -123,14 +144,15 @@ if (newAccount) {
123
144
  pod = made.pod;
124
145
  console.log(`account + pod created: ${pod}`);
125
146
  if (!webfingerHost(pod)) {
126
- console.log(`\n${issuerHost} created the pod at a path rather than on its own subdomain,`);
127
- console.log(`so @${handle}@\u2026 cannot be discovered by other Fediverse servers.`);
128
- const cont = kind === 'group' ? 'n' : (interactive ? await ask('continue anyway? (y/n)', 'n') : 'y');
129
- endAsking();
130
- if (!/^y/i.test(cont)) {
131
- console.log('stopping \u2014 the pod exists, but no actor was published');
147
+ if (kind === 'group') {
148
+ console.log(`\n${issuerHost} created the pod at ${pod} a suffix-based host.`);
149
+ console.log('WebFinger is answered only at a host root, so nobody could find this group.');
150
+ console.log('The pod exists; no actor was published.');
132
151
  process.exit(0);
133
152
  }
153
+ // A path pod's address lives at the gateway; nothing to warn about.
154
+ fronted = true;
155
+ console.log(`${issuerHost} puts pods on paths, so your address will be @${handle}@${gatewayHost}.`);
134
156
  }
135
157
  }
136
158
 
@@ -164,13 +186,23 @@ if (rootOf(HOME) === AP_ROOT) recordLastUsed(AP_ROOT, path.basename(HOME));
164
186
  recordAgent({ port: PORT, handle }); // later commands need no --port
165
187
  console.log(`credential minted and saved to ${path.join(HOME, 'credential.json')}`);
166
188
 
189
+ let gatewayCfg = null;
190
+ if (fronted) {
191
+ const { takeGatewayAddress } = await import(new URL('../../../../lib/device/setup.mjs', import.meta.url));
192
+ const urls = apUrls(pod, root || DEFAULT_ROOT);
193
+ console.log(`taking a gateway address at ${gatewayHost}`);
194
+ gatewayCfg = await takeGatewayAddress({
195
+ home: HOME, credential: rec, gatewayOrigin, handle,
196
+ podHome: urls.home, actorUrl: urls.actor, kind, log: (...a) => console.log('[setup]', ...a),
197
+ });
198
+ }
167
199
  const { Agent } = await import(new URL('../../../../run-agent.mjs', import.meta.url));
168
200
  const agent = new Agent({ home: HOME, log: (...a) => console.log('[setup]', ...a) });
169
- await agent.bootstrap({ handle, name, root, kind, approveJoins, summary, icon });
201
+ await agent.bootstrap({ handle, name, root, kind, approveJoins, summary, icon, gateway: gatewayCfg });
170
202
  await agent.connect({ repair: false }); // publishProfile below is the publish
171
203
  await agent.publisher.publishProfile();
172
204
  await agent.store.flush();
173
- const finalHost = webfingerHost(rec.remotePod);
205
+ const finalHost = gatewayCfg ? gatewayHost : webfingerHost(rec.remotePod);
174
206
  const what = kind === 'group' ? 'group' : 'actor';
175
207
  console.log(finalHost
176
208
  ? `${what} published: @${handle}@${finalHost}`
@@ -14,11 +14,12 @@ import { pathToFileURL } from 'node:url';
14
14
  import * as $rdf from 'rdflib';
15
15
 
16
16
  import { createAccountWithPod as realCreateAccount } from './account.mjs';
17
- import { mintCredential as realMint } from './remote.mjs';
17
+ import { mintCredential as realMint, RemotePod } from './remote.mjs';
18
18
  import { hashPassword } from '../client/masto/index.mjs';
19
- import { webfingerHost } from '../core/wire.mjs';
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#');
@@ -96,6 +97,11 @@ export function setupInputError(a, resuming = false) {
96
97
  const badGw = insecureUrlReason(a.gateway.url, 'gateway address');
97
98
  if (badGw) return badGw;
98
99
  }
100
+ if (a.shape && a.shape !== 'pod' && a.shape !== 'front') return 'shape must be "pod" or "front"';
101
+ if (a.gatewayOrigin) {
102
+ const badO = insecureUrlReason(a.gatewayOrigin, 'gateway address');
103
+ if (badO) return badO;
104
+ }
99
105
  if (resuming) return null;
100
106
  if (a.mode !== 'new' && a.mode !== 'existing') return 'mode must be "new" or "existing"';
101
107
  if (!a.issuer) return 'an identity provider is required';
@@ -111,23 +117,61 @@ export function setupInputError(a, resuming = false) {
111
117
  return null;
112
118
  }
113
119
 
120
+ // Where an identity's address lives. A pod on a suffix-based host cannot
121
+ // answer WebFinger for a handle, so its address must live at the Gateway
122
+ // whatever was asked; a pod at its own host root takes the shape chosen.
123
+ export function frontedAddress({ pod, shape }) {
124
+ const pathPod = pod ? !webfingerHost(pod) : false;
125
+ return pathPod || shape === 'front';
126
+ }
127
+
128
+ // Take an address at a Gateway: attach the pod to it, fronted, and return the
129
+ // gateway config bootstrap writes and connect reads. The pod session proves
130
+ // the pod — no password reaches the Gateway. Injected for tests.
131
+ export async function takeGatewayAddress({ home, credential, gatewayOrigin, handle, podHome, actorUrl, kind, log = () => {} }) {
132
+ const remote = new RemotePod(credential, { home, log });
133
+ await remote.warmup?.();
134
+ const origin = String(gatewayOrigin).replace(/\/$/, '');
135
+ const res = await remote.session.fetch(`${origin}/api/attach`, {
136
+ method: 'POST', headers: { 'content-type': 'application/json' },
137
+ body: JSON.stringify({ handle, podHome, actorUrl, kind: kind === 'group' ? 'group' : 'person', fronted: true }),
138
+ });
139
+ const d = await res.json().catch(() => ({}));
140
+ if (res.status !== 201 || !d.hmacSecret) {
141
+ throw new Error(`could not take a gateway address at ${new URL(origin).host} (HTTP ${res.status})${d.error ? ': ' + d.error : ''}`);
142
+ }
143
+ return {
144
+ url: `${origin}/u/${handle}/ap/inbox/`,
145
+ frontActor: String(d.frontActor || `${origin}/u/${handle}/ap/actor`),
146
+ hmacSecret: String(d.hmacSecret), mode: 'trust',
147
+ };
148
+ }
149
+
114
150
  // What the CLI printed before asking "create pod and fediverse account?"
115
151
  // (bin/fedipod.mjs, the address preview) — as data, so the page can show
116
152
  // the same warnings. Pure: no network, so it can answer while you type.
117
- export function preflight({ mode, pod, issuer, podName, handle, kind }) {
153
+ export function preflight({ mode, pod, issuer, podName, handle, kind, shape = 'pod', gatewayOrigin = 'https://fedipod.net' }) {
118
154
  const warnings = [];
119
155
  if (!handle) return { ok: false, error: 'a handle is required' };
120
156
  let issuerHost;
121
157
  try { issuerHost = new URL(issuer || 'https://solidcommunity.net').host; }
122
158
  catch { return { ok: false, error: `"${issuer}" is not a URL` }; }
123
159
 
160
+ let gwHost; try { gwHost = new URL(gatewayOrigin).host; } catch { gwHost = 'fedipod.net'; }
161
+
124
162
  if (mode === 'new') {
125
- // No warning about whether the server gives the pod its own subdomain. The
126
- // run checks what it actually got and fails the account step when a group
127
- // lands on a shared host, which is the case that matters; saying it up
128
- // front only made the form noisy.
163
+ // Whether the provider puts the pod on its own subdomain or on a path is
164
+ // the provider's call, learned only once the pod is made. If the address
165
+ // was asked to live at the Gateway, it does. Otherwise the pod's own host
166
+ // is the address, and the run moves it to the Gateway if the provider
167
+ // turned out to use paths.
168
+ if (shape === 'front') {
169
+ if (kind === 'group') return { ok: false, mode, handle, kind, error: 'group-needs-host-root', refusal: 'group-needs-host-root', warnings };
170
+ return { ok: true, mode, handle, kind, fronted: true, forced: false, gatewayHost: gwHost, shape: 'front',
171
+ address: `@${handle}@${gwHost}`, webfingerHost: null, resolvable: true, warnings, refusal: null };
172
+ }
129
173
  return {
130
- ok: true, mode, handle, kind,
174
+ ok: true, mode, handle, kind, fronted: false, shape: 'pod',
131
175
  address: `@${handle}@${podName || handle}.${issuerHost}`,
132
176
  webfingerHost: null, resolvable: null, warnings, refusal: null,
133
177
  };
@@ -137,20 +181,26 @@ export function preflight({ mode, pod, issuer, podName, handle, kind }) {
137
181
  try { podUrl = new URL(pod); }
138
182
  catch { return { ok: false, error: `"${pod}" is not a pod address` }; }
139
183
  // A handle resolves through <host>/.well-known/webfinger, so it only works
140
- // when the pod owns the root of its host.
184
+ // when the pod owns the root of its host. A pod that does not can still be
185
+ // an account, with its address at the Gateway.
141
186
  const wfHost = webfingerHost(podUrl.href);
142
- let refusal = null;
143
- if (!wfHost) {
144
- warnings.push('pod-is-a-path');
145
- // A person warned about an unresolvable handle is the one who suffers, so
146
- // that is their call to accept. Nobody could ever find this group, and the
147
- // people it would fail are not the operator reading the warning.
148
- if (kind === 'group') refusal = 'group-needs-host-root';
187
+ const fronted = !wfHost || shape === 'front';
188
+ if (fronted) {
189
+ // A person's address moves to the Gateway; a group cannot, yet — a group
190
+ // needs a pod at its own host until fronted groups are proven.
191
+ if (kind === 'group') {
192
+ return { ok: false, mode: 'existing', handle, kind, refusal: 'group-needs-host-root', warnings,
193
+ address: `@${handle}@${podUrl.host}`, webfingerHost: null, resolvable: false };
194
+ }
195
+ return {
196
+ ok: true, mode: 'existing', handle, kind, fronted: true, forced: !wfHost, gatewayHost: gwHost, shape: 'front',
197
+ address: `@${handle}@${gwHost}`, webfingerHost: null, resolvable: true, warnings, refusal: null,
198
+ };
149
199
  }
150
200
  return {
151
- ok: !refusal, mode: 'existing', handle, kind,
152
- address: `@${handle}@${wfHost || podUrl.host}`,
153
- webfingerHost: wfHost, resolvable: !!wfHost, warnings, refusal,
201
+ ok: true, mode: 'existing', handle, kind, fronted: false, shape: 'pod',
202
+ address: `@${handle}@${wfHost}`,
203
+ webfingerHost: wfHost, resolvable: true, warnings, refusal: null,
154
204
  };
155
205
  }
156
206
 
@@ -160,6 +210,7 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
160
210
  const createAccount = deps.createAccountWithPod || realCreateAccount;
161
211
  const mint = deps.mintCredential || realMint;
162
212
  const checkPod = deps.checkPodUsable || checkPodUsable;
213
+ const attachGateway = deps.attachGateway || takeGatewayAddress;
163
214
 
164
215
  const at = (key) => run.steps.find(s => s.key === key);
165
216
  const begin = (key) => { at(key).state = 'running'; };
@@ -169,9 +220,11 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
169
220
  const {
170
221
  mode, issuer, email, password, handle, name, podName,
171
222
  kind = 'person', approveJoins = false, summary, icon, keys, uiPassword,
172
- gateway = null,
223
+ gateway = null, shape = 'pod', gatewayOrigin = 'https://fedipod.net',
173
224
  } = answers;
174
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.
175
228
  let accountWebId = null; // what createAccountWithPod reported, when it ran
176
229
  // The private half always starts here, beside the credential and the keys —
177
230
  // not on the pod. Every activity you receive would otherwise cost the pod
@@ -202,7 +255,7 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
202
255
  // nobody could ever find this group. A person was warned before we got
203
256
  // here and chose to continue; a group cannot.
204
257
  if (kind === 'group' && !webfingerHost(pod)) {
205
- throw new Error(`${issuer} created the pod at ${pod} — a path on a shared host, `
258
+ throw new Error(`${issuer} created the pod at ${pod} — a suffix-based host, `
206
259
  + 'not a host root. WebFinger is only answered at a host root, so nobody '
207
260
  + 'could find this group. The pod exists; no actor was published.');
208
261
  }
@@ -213,6 +266,9 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
213
266
  // reachable, and no silent 401 later on a pod whose profile is empty.
214
267
  const usable = await checkPod(pod);
215
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
+ }
216
272
  skip('account', 'using the pod you already have');
217
273
  }
218
274
 
@@ -259,6 +315,26 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
259
315
  done('credential', credPath);
260
316
  }
261
317
 
318
+ // --- take an address at the Gateway, if this pod needs one or asked for
319
+ // one --- before bootstrap, so the config it writes carries the Gateway
320
+ // ids and the key connect mints below is stamped to the Gateway actor from
321
+ // the start. A signup-arranged gateway (the installer carry-over) wins.
322
+ let gatewayCfg = gateway;
323
+ if (!gatewayCfg && frontedAddress({ pod, shape })) {
324
+ if (kind === 'group') {
325
+ throw new Error(`a group needs a pod at its own host — a Gateway address for a group is not supported yet`
326
+ + (webfingerHost(pod) ? '' : `; ${pod} is a suffix-based host`) + '.');
327
+ }
328
+ const urls = apUrls(pod, root || DEFAULT_ROOT);
329
+ const cred = JSON.parse(fs.readFileSync(credPath, 'utf8'));
330
+ log(`taking a gateway address at ${new URL(gatewayOrigin).host}`);
331
+ gatewayCfg = await attachGateway({
332
+ home, credential: cred, gatewayOrigin, handle,
333
+ podHome: urls.home, actorUrl: urls.actor, kind, log,
334
+ });
335
+ log(`gateway address @${handle}@${new URL(gatewayOrigin).host}`);
336
+ }
337
+
262
338
  // --- provision the pod and bring federation up ---
263
339
  begin('bootstrap');
264
340
  // Resuming skipped the checks the fresh paths ran, and the credential it
@@ -268,7 +344,7 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
268
344
  const ready = await checkPod(pod, { webId: resumeWebId || undefined });
269
345
  if (!ready.ok) throw new Error(ready.error);
270
346
  }
271
- await agent.bootstrap({ handle, name: name || handle, root, kind, approveJoins, summary, icon, gateway });
347
+ await agent.bootstrap({ handle, name: name || handle, root, kind, approveJoins, summary, icon, gateway: gatewayCfg });
272
348
  done('bootstrap');
273
349
 
274
350
  begin('connect');
@@ -293,14 +369,18 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
293
369
  ? `not readable without credentials: ${unreachable.join(', ')}`
294
370
  : 'the public surface is reachable');
295
371
 
372
+ // A fronted identity resolves at the Gateway, not the pod host.
373
+ const frontActor = gatewayCfg?.frontActor || agent.store.getConfig()?.gateway?.frontActor || null;
374
+ const gwHost = frontActor ? new URL(frontActor).host : null;
296
375
  run.result = {
297
376
  kind,
298
377
  pod,
299
378
  handle,
300
379
  actor: agent.urls?.actor || null,
301
- webfingerHost: wfHost,
302
- resolvable: !!wfHost,
303
- address: wfHost ? `@${handle}@${wfHost}` : null,
380
+ fronted: !!frontActor,
381
+ webfingerHost: gwHost || wfHost,
382
+ resolvable: !!(gwHost || wfHost),
383
+ address: gwHost ? `@${handle}@${gwHost}` : (wfHost ? `@${handle}@${wfHost}` : null),
304
384
  unreachable,
305
385
  };
306
386
  run.phase = 'done';
@@ -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
@@ -41,7 +41,7 @@ export async function readOwnerLinks(fetchImpl, podBase, { timeoutMs = OWNER_LOO
41
41
  */
42
42
  /**
43
43
  * Where a provider puts its pods: on hosts of their own, or on paths of one
44
- * shared host. No spec says. What does say is the storage description at the
44
+ * suffix-based host. No spec says. What does say is the storage description at the
45
45
  * provider's root: a CSS that keeps pods on subdomains answers 501 there,
46
46
  * because its root is not a storage; one that keeps them on paths answers 200
47
47
  * with the root described as a storage. Anything else is unknown.
@@ -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.15.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
  });
@@ -130,9 +130,20 @@ button:disabled { opacity: .5; cursor: default; }
130
130
  <label for="issuer-new">Solid pod provider</label>
131
131
  <select id="issuer-new" name="issuerNew" aria-describedby="issuer-new-hint">
132
132
  <option value="https://solidcommunity.net">solidcommunity.net</option>
133
+ <option value="https://privatedatapod.com">privatedatapod.com</option>
134
+ <option value="https://solid.redpencil.io">redpencil.io</option>
135
+ <option value="https://pods.solidcommunity.au">solidcommunity.au</option>
136
+ <option value="https://solidweb.me">solidweb.me</option>
137
+ <option value="https://teamid.live">teamid.live</option>
138
+ <option value="">Other…</option>
133
139
  </select>
134
- <p class="hint" id="issuer-new-hint">Providers that give each pod its own subdomain, so the
135
- Fediverse address works everywhere.</p>
140
+ <div id="row-issuer-other" hidden>
141
+ <label for="issuerOther">Provider address</label>
142
+ <input type="url" id="issuerOther" name="issuerOther" autocomplete="url" placeholder="https://pods.example.org">
143
+ </div>
144
+ <p class="hint" id="issuer-new-hint">The Community Solid Server providers from
145
+ solidproject.org. A provider that puts pods on paths gives your address at a
146
+ gateway instead.</p>
136
147
  </div>
137
148
 
138
149
  <div id="row-issuer-existing" hidden>
@@ -170,6 +181,18 @@ button:disabled { opacity: .5; cursor: default; }
170
181
  Wrong account or pod?
171
182
  <button type="button" id="form-reenter" title=" Discard the credential and enter the account and pod again">Re-enter credentials</button></p>
172
183
 
184
+ <fieldset id="fs-shape" hidden>
185
+ <legend>Where your address lives</legend>
186
+ <label class="choice"><input type="radio" name="shape" value="pod" checked> On your pod</label>
187
+ <label class="choice"><input type="radio" name="shape" value="front"> At a gateway</label>
188
+ <div id="row-gateway" hidden>
189
+ <label for="gatewayOrigin">Gateway</label>
190
+ <input type="url" id="gatewayOrigin" name="gatewayOrigin" value="https://fedipod.net" autocomplete="url">
191
+ <p class="hint">The gateway answers your address and forwards your mail to your pod.</p>
192
+ </div>
193
+ <p class="hint" id="shape-note" hidden></p>
194
+ </fieldset>
195
+
173
196
  <h2>You will be</h2>
174
197
  <p class="address" id="preview">…</p>
175
198
  <div id="preview-notes"></div>