fedipod-server 0.14.1 → 0.16.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/README.md CHANGED
@@ -32,7 +32,7 @@ sign-up refuses rather than half-working.
32
32
  - **Community Solid Server 7.**
33
33
  - **Pods on subdomains.** Each account answers on its own pod's address, so
34
34
  every pod needs a host to itself. A server that puts its pods on paths of
35
- one shared host cannot offer accounts at all.
35
+ one suffix-based host cannot offer accounts at all.
36
36
  - **A single worker.** Run the server as one copy of itself — `--workers 1`,
37
37
  which is the default. With more, it still serves pods normally, but no pod
38
38
  can be an account: sign-up is refused, and an account set up earlier goes on
@@ -78,7 +78,12 @@ export class MastoApi {
78
78
  });
79
79
  return this._push;
80
80
  }
81
- get host() { return this.urls ? new URL(this.urls.base).host : 'unconfigured.invalid'; }
81
+ // The host in the owner's own address: the gateway's when the identity is
82
+ // fronted (its documents still live on the pod, but its name does not).
83
+ get host() {
84
+ if (!this.urls) return 'unconfigured.invalid';
85
+ return new URL(this.urls.publicHome || this.urls.base).host;
86
+ }
82
87
 
83
88
  // Where the live feed is, as the CLIENT must address it: this agent's own
84
89
  // origin, taken from the request, not the pod's host. An instance document
@@ -201,7 +201,7 @@ export class Publisher {
201
201
  this.log(this.config.gateway?.frontActor || wire.webfingerHost(urls.base)
202
202
  ? `profile published: @${pubName}@${pubHost} → ${urls.actor}`
203
203
  : `profile published → ${urls.actor} — NOT discoverable as @${pubName}@${pubHost}: `
204
- + 'this pod is a path on a shared host, and WebFinger is only answered at a host root');
204
+ + 'this pod is a suffix-based host, and WebFinger is only answered at a host root');
205
205
  return { unreachable, updated };
206
206
  }
207
207
 
package/lib/core/wire.mjs CHANGED
@@ -49,9 +49,10 @@ export function hostMeta(base) {
49
49
  return `<?xml version="1.0" encoding="UTF-8"?>\n<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">\n <Link rel="lrdd" template="${base}.well-known/webfinger?resource={uri}"/>\n</XRD>\n`;
50
50
  }
51
51
 
52
- export function jrd({ handle, host, actor }) {
52
+ export function jrd({ handle, host, actor, aliases = [] }) {
53
53
  return {
54
54
  subject: `acct:${handle}@${host}`,
55
+ ...(aliases.length ? { aliases } : {}),
55
56
  links: [{ rel: 'self', type: 'application/activity+json', href: actor }],
56
57
  };
57
58
  }
@@ -102,7 +102,7 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
102
102
  // A Move target nobody can resolve is a landing pad nobody lands on.
103
103
  if (!webfingerHost(urls.base) && !cfg.gateway?.frontActor) {
104
104
  return json(res, 400, {
105
- error: 'this pod is a path on a shared host, so WebFinger cannot answer for it '
105
+ error: 'this pod is a suffix-based host, so WebFinger cannot answer for it '
106
106
  + '— other servers could never resolve this account as a Move target',
107
107
  });
108
108
  }
@@ -12,6 +12,7 @@ import { identityHomes, rootOf, tildify, writeJsonAtomic } from '../../home.mjs'
12
12
  import { copyPrivateHalf, isCurrent, CURRENT_LAYOUT } from '../../migrate.mjs';
13
13
  import { insecureUrlReason } from '../../../shared/safefetch.mjs';
14
14
  import { newRun, preflight, runSetup, setupInputError, hasCredential, credentialPath } from '../../setup.mjs';
15
+ import { podLayout } from '../../../pod/root.mjs';
15
16
  import { portFree, freePortFrom } from '../../ports.mjs';
16
17
  import { yieldDirectory } from '../../../gateway/directory.mjs';
17
18
  import { localFetch } from '../../../client/localapi.mjs';
@@ -113,7 +114,25 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
113
114
  return true;
114
115
  }
115
116
  // ---- setup, driven by the page at /admin/setup/ ----
116
- case '/setup/check': return json(res, 200, preflight(body));
117
+ case '/setup/check': {
118
+ const pre = preflight(body);
119
+ // For a new pod, ask the provider where it puts pods, so the page can
120
+ // show the Gateway address before the pod exists. A provider on paths
121
+ // means the address must live at the Gateway; the run decides for real.
122
+ if (pre.ok && body.mode === 'new' && body.shape !== 'front') {
123
+ const layout = await podLayout(fetch, body.issuer || '').catch(() => null);
124
+ if (layout === 'path') {
125
+ if ((body.kind || 'person') === 'group') {
126
+ return json(res, 200, { ...pre, ok: false, layout, refusal: 'group-needs-host-root' });
127
+ }
128
+ let gwHost; try { gwHost = new URL(body.gatewayOrigin || 'https://fedipod.net').host; } catch { gwHost = 'fedipod.net'; }
129
+ return json(res, 200, { ...pre, layout, fronted: true, forced: true, shape: 'front', gatewayHost: gwHost,
130
+ address: `@${body.handle}@${gwHost}`, resolvable: true });
131
+ }
132
+ return json(res, 200, { ...pre, layout });
133
+ }
134
+ return json(res, 200, pre);
135
+ }
117
136
  // Discard a credential that never finished setup, so the account and
118
137
  // pod can be entered again. The credential a CSS server mints is shown
119
138
  // once, so a setup that stops after the mint (a wrong pod answers 401
@@ -79,9 +79,26 @@ const name = flag('name') || await ask('display name (shown above your address)'
79
79
  // A handle resolves through <host>/.well-known/webfinger, so it only works
80
80
  // when the pod owns the root of its host. Whether a NEW pod gets its own
81
81
  // 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));
82
+ const { webfingerHost, apUrls, DEFAULT_ROOT } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
83
83
  const issuerHost = new URL(issuer).host;
84
84
  const wfHost = newAccount ? null : webfingerHost(pod);
85
+
86
+ // Where the address lives: on the pod (default), or at a gateway. A pod on a
87
+ // suffix-based host cannot answer WebFinger for a handle, so its address
88
+ // is at the gateway whatever was asked. `--address front` takes a gateway
89
+ // address for a host-root pod too; `--gateway <origin>` names the gateway.
90
+ const addressShape = String(flag('address') || 'pod').toLowerCase();
91
+ if (addressShape !== 'pod' && addressShape !== 'front') { console.error('--address must be pod or front'); process.exit(2); }
92
+ const gatewayOrigin = flag('gateway') || 'https://fedipod.net';
93
+ { const badGw = insecureUrlReason(gatewayOrigin, 'gateway address'); if (badGw) { console.error(badGw); process.exit(2); } }
94
+ const gatewayHost = new URL(gatewayOrigin).host;
95
+ // For an EXISTING pod the shape is known now; for a NEW pod the path case is
96
+ // decided after the pod is made, below.
97
+ let fronted = (!newAccount && !wfHost) || addressShape === 'front';
98
+ if (fronted && kind === 'group') {
99
+ console.error('a group needs a pod at its own host — a gateway address for a group is not supported yet.');
100
+ process.exit(2);
101
+ }
85
102
  // A person warned about an unresolvable handle is the one who suffers, so a
86
103
  // warning is their call to accept. Nobody could ever find this group, and the
87
104
  // people it would fail are not the operator reading the warning.
@@ -93,20 +110,16 @@ if (kind === 'group' && !newAccount && !wfHost) {
93
110
  }
94
111
  console.log(kind === 'group' ? '\nThe group will be:\n' : '\nYou will be:\n');
95
112
  console.log(` ${name}`);
96
- if (wfHost) {
113
+ if (fronted) {
114
+ console.log(` @${handle}@${gatewayHost}\n`);
115
+ console.log(`— your address lives at ${gatewayHost}; your posts, key and data stay on your pod.\n`);
116
+ } else if (wfHost) {
97
117
  console.log(` @${handle}@${wfHost}\n`);
98
118
  } else if (newAccount) {
99
119
  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');
120
+ console.log(`— provided ${issuerHost} gives each pod its own subdomain. A server that puts`);
121
+ console.log(`pods at ${issuerHost}/${podName}/ instead cannot answer WebFinger for an address,`);
122
+ console.log(`so setup takes an address at ${gatewayHost} for you and says so.\n`);
110
123
  }
111
124
  console.log('The display name can be changed later; the handle and pod cannot.');
112
125
  const go = await ask(newAccount
@@ -123,14 +136,15 @@ if (newAccount) {
123
136
  pod = made.pod;
124
137
  console.log(`account + pod created: ${pod}`);
125
138
  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');
139
+ if (kind === 'group') {
140
+ console.log(`\n${issuerHost} created the pod at ${pod} a suffix-based host.`);
141
+ console.log('WebFinger is answered only at a host root, so nobody could find this group.');
142
+ console.log('The pod exists; no actor was published.');
132
143
  process.exit(0);
133
144
  }
145
+ // A path pod's address lives at the gateway; nothing to warn about.
146
+ fronted = true;
147
+ console.log(`${issuerHost} puts pods on paths, so your address will be @${handle}@${gatewayHost}.`);
134
148
  }
135
149
  }
136
150
 
@@ -164,13 +178,23 @@ if (rootOf(HOME) === AP_ROOT) recordLastUsed(AP_ROOT, path.basename(HOME));
164
178
  recordAgent({ port: PORT, handle }); // later commands need no --port
165
179
  console.log(`credential minted and saved to ${path.join(HOME, 'credential.json')}`);
166
180
 
181
+ let gatewayCfg = null;
182
+ if (fronted) {
183
+ const { takeGatewayAddress } = await import(new URL('../../../../lib/device/setup.mjs', import.meta.url));
184
+ const urls = apUrls(pod, root || DEFAULT_ROOT);
185
+ console.log(`taking a gateway address at ${gatewayHost}`);
186
+ gatewayCfg = await takeGatewayAddress({
187
+ home: HOME, credential: rec, gatewayOrigin, handle,
188
+ podHome: urls.home, actorUrl: urls.actor, kind, log: (...a) => console.log('[setup]', ...a),
189
+ });
190
+ }
167
191
  const { Agent } = await import(new URL('../../../../run-agent.mjs', import.meta.url));
168
192
  const agent = new Agent({ home: HOME, log: (...a) => console.log('[setup]', ...a) });
169
- await agent.bootstrap({ handle, name, root, kind, approveJoins, summary, icon });
193
+ await agent.bootstrap({ handle, name, root, kind, approveJoins, summary, icon, gateway: gatewayCfg });
170
194
  await agent.connect({ repair: false }); // publishProfile below is the publish
171
195
  await agent.publisher.publishProfile();
172
196
  await agent.store.flush();
173
- const finalHost = webfingerHost(rec.remotePod);
197
+ const finalHost = gatewayCfg ? gatewayHost : webfingerHost(rec.remotePod);
174
198
  const what = kind === 'group' ? 'group' : 'actor';
175
199
  console.log(finalHost
176
200
  ? `${what} published: @${handle}@${finalHost}`
@@ -14,9 +14,9 @@ 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
22
  import { CURRENT_LAYOUT, isCurrent } from './migrate.mjs';
@@ -96,6 +96,11 @@ export function setupInputError(a, resuming = false) {
96
96
  const badGw = insecureUrlReason(a.gateway.url, 'gateway address');
97
97
  if (badGw) return badGw;
98
98
  }
99
+ if (a.shape && a.shape !== 'pod' && a.shape !== 'front') return 'shape must be "pod" or "front"';
100
+ if (a.gatewayOrigin) {
101
+ const badO = insecureUrlReason(a.gatewayOrigin, 'gateway address');
102
+ if (badO) return badO;
103
+ }
99
104
  if (resuming) return null;
100
105
  if (a.mode !== 'new' && a.mode !== 'existing') return 'mode must be "new" or "existing"';
101
106
  if (!a.issuer) return 'an identity provider is required';
@@ -111,23 +116,61 @@ export function setupInputError(a, resuming = false) {
111
116
  return null;
112
117
  }
113
118
 
119
+ // Where an identity's address lives. A pod on a suffix-based host cannot
120
+ // answer WebFinger for a handle, so its address must live at the Gateway
121
+ // whatever was asked; a pod at its own host root takes the shape chosen.
122
+ export function frontedAddress({ pod, shape }) {
123
+ const pathPod = pod ? !webfingerHost(pod) : false;
124
+ return pathPod || shape === 'front';
125
+ }
126
+
127
+ // Take an address at a Gateway: attach the pod to it, fronted, and return the
128
+ // gateway config bootstrap writes and connect reads. The pod session proves
129
+ // the pod — no password reaches the Gateway. Injected for tests.
130
+ export async function takeGatewayAddress({ home, credential, gatewayOrigin, handle, podHome, actorUrl, kind, log = () => {} }) {
131
+ const remote = new RemotePod(credential, { home, log });
132
+ await remote.warmup?.();
133
+ const origin = String(gatewayOrigin).replace(/\/$/, '');
134
+ const res = await remote.session.fetch(`${origin}/api/attach`, {
135
+ method: 'POST', headers: { 'content-type': 'application/json' },
136
+ body: JSON.stringify({ handle, podHome, actorUrl, kind: kind === 'group' ? 'group' : 'person', fronted: true }),
137
+ });
138
+ const d = await res.json().catch(() => ({}));
139
+ if (res.status !== 201 || !d.hmacSecret) {
140
+ throw new Error(`could not take a gateway address at ${new URL(origin).host} (HTTP ${res.status})${d.error ? ': ' + d.error : ''}`);
141
+ }
142
+ return {
143
+ url: `${origin}/u/${handle}/ap/inbox/`,
144
+ frontActor: String(d.frontActor || `${origin}/u/${handle}/ap/actor`),
145
+ hmacSecret: String(d.hmacSecret), mode: 'trust',
146
+ };
147
+ }
148
+
114
149
  // What the CLI printed before asking "create pod and fediverse account?"
115
150
  // (bin/fedipod.mjs, the address preview) — as data, so the page can show
116
151
  // the same warnings. Pure: no network, so it can answer while you type.
117
- export function preflight({ mode, pod, issuer, podName, handle, kind }) {
152
+ export function preflight({ mode, pod, issuer, podName, handle, kind, shape = 'pod', gatewayOrigin = 'https://fedipod.net' }) {
118
153
  const warnings = [];
119
154
  if (!handle) return { ok: false, error: 'a handle is required' };
120
155
  let issuerHost;
121
156
  try { issuerHost = new URL(issuer || 'https://solidcommunity.net').host; }
122
157
  catch { return { ok: false, error: `"${issuer}" is not a URL` }; }
123
158
 
159
+ let gwHost; try { gwHost = new URL(gatewayOrigin).host; } catch { gwHost = 'fedipod.net'; }
160
+
124
161
  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.
162
+ // Whether the provider puts the pod on its own subdomain or on a path is
163
+ // the provider's call, learned only once the pod is made. If the address
164
+ // was asked to live at the Gateway, it does. Otherwise the pod's own host
165
+ // is the address, and the run moves it to the Gateway if the provider
166
+ // turned out to use paths.
167
+ if (shape === 'front') {
168
+ if (kind === 'group') return { ok: false, mode, handle, kind, error: 'group-needs-host-root', refusal: 'group-needs-host-root', warnings };
169
+ return { ok: true, mode, handle, kind, fronted: true, forced: false, gatewayHost: gwHost, shape: 'front',
170
+ address: `@${handle}@${gwHost}`, webfingerHost: null, resolvable: true, warnings, refusal: null };
171
+ }
129
172
  return {
130
- ok: true, mode, handle, kind,
173
+ ok: true, mode, handle, kind, fronted: false, shape: 'pod',
131
174
  address: `@${handle}@${podName || handle}.${issuerHost}`,
132
175
  webfingerHost: null, resolvable: null, warnings, refusal: null,
133
176
  };
@@ -137,20 +180,26 @@ export function preflight({ mode, pod, issuer, podName, handle, kind }) {
137
180
  try { podUrl = new URL(pod); }
138
181
  catch { return { ok: false, error: `"${pod}" is not a pod address` }; }
139
182
  // A handle resolves through <host>/.well-known/webfinger, so it only works
140
- // when the pod owns the root of its host.
183
+ // when the pod owns the root of its host. A pod that does not can still be
184
+ // an account, with its address at the Gateway.
141
185
  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';
186
+ const fronted = !wfHost || shape === 'front';
187
+ if (fronted) {
188
+ // A person's address moves to the Gateway; a group cannot, yet — a group
189
+ // needs a pod at its own host until fronted groups are proven.
190
+ if (kind === 'group') {
191
+ return { ok: false, mode: 'existing', handle, kind, refusal: 'group-needs-host-root', warnings,
192
+ address: `@${handle}@${podUrl.host}`, webfingerHost: null, resolvable: false };
193
+ }
194
+ return {
195
+ ok: true, mode: 'existing', handle, kind, fronted: true, forced: !wfHost, gatewayHost: gwHost, shape: 'front',
196
+ address: `@${handle}@${gwHost}`, webfingerHost: null, resolvable: true, warnings, refusal: null,
197
+ };
149
198
  }
150
199
  return {
151
- ok: !refusal, mode: 'existing', handle, kind,
152
- address: `@${handle}@${wfHost || podUrl.host}`,
153
- webfingerHost: wfHost, resolvable: !!wfHost, warnings, refusal,
200
+ ok: true, mode: 'existing', handle, kind, fronted: false, shape: 'pod',
201
+ address: `@${handle}@${wfHost}`,
202
+ webfingerHost: wfHost, resolvable: true, warnings, refusal: null,
154
203
  };
155
204
  }
156
205
 
@@ -160,6 +209,7 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
160
209
  const createAccount = deps.createAccountWithPod || realCreateAccount;
161
210
  const mint = deps.mintCredential || realMint;
162
211
  const checkPod = deps.checkPodUsable || checkPodUsable;
212
+ const attachGateway = deps.attachGateway || takeGatewayAddress;
163
213
 
164
214
  const at = (key) => run.steps.find(s => s.key === key);
165
215
  const begin = (key) => { at(key).state = 'running'; };
@@ -169,7 +219,7 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
169
219
  const {
170
220
  mode, issuer, email, password, handle, name, podName,
171
221
  kind = 'person', approveJoins = false, summary, icon, keys, uiPassword,
172
- gateway = null,
222
+ gateway = null, shape = 'pod', gatewayOrigin = 'https://fedipod.net',
173
223
  } = answers;
174
224
  let { pod, root } = answers;
175
225
  let accountWebId = null; // what createAccountWithPod reported, when it ran
@@ -202,7 +252,7 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
202
252
  // nobody could ever find this group. A person was warned before we got
203
253
  // here and chose to continue; a group cannot.
204
254
  if (kind === 'group' && !webfingerHost(pod)) {
205
- throw new Error(`${issuer} created the pod at ${pod} — a path on a shared host, `
255
+ throw new Error(`${issuer} created the pod at ${pod} — a suffix-based host, `
206
256
  + 'not a host root. WebFinger is only answered at a host root, so nobody '
207
257
  + 'could find this group. The pod exists; no actor was published.');
208
258
  }
@@ -259,6 +309,26 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
259
309
  done('credential', credPath);
260
310
  }
261
311
 
312
+ // --- take an address at the Gateway, if this pod needs one or asked for
313
+ // one --- before bootstrap, so the config it writes carries the Gateway
314
+ // ids and the key connect mints below is stamped to the Gateway actor from
315
+ // the start. A signup-arranged gateway (the installer carry-over) wins.
316
+ let gatewayCfg = gateway;
317
+ if (!gatewayCfg && frontedAddress({ pod, shape })) {
318
+ if (kind === 'group') {
319
+ throw new Error(`a group needs a pod at its own host — a Gateway address for a group is not supported yet`
320
+ + (webfingerHost(pod) ? '' : `; ${pod} is a suffix-based host`) + '.');
321
+ }
322
+ const urls = apUrls(pod, root || DEFAULT_ROOT);
323
+ const cred = JSON.parse(fs.readFileSync(credPath, 'utf8'));
324
+ log(`taking a gateway address at ${new URL(gatewayOrigin).host}`);
325
+ gatewayCfg = await attachGateway({
326
+ home, credential: cred, gatewayOrigin, handle,
327
+ podHome: urls.home, actorUrl: urls.actor, kind, log,
328
+ });
329
+ log(`gateway address @${handle}@${new URL(gatewayOrigin).host}`);
330
+ }
331
+
262
332
  // --- provision the pod and bring federation up ---
263
333
  begin('bootstrap');
264
334
  // Resuming skipped the checks the fresh paths ran, and the credential it
@@ -268,7 +338,7 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
268
338
  const ready = await checkPod(pod, { webId: resumeWebId || undefined });
269
339
  if (!ready.ok) throw new Error(ready.error);
270
340
  }
271
- await agent.bootstrap({ handle, name: name || handle, root, kind, approveJoins, summary, icon, gateway });
341
+ await agent.bootstrap({ handle, name: name || handle, root, kind, approveJoins, summary, icon, gateway: gatewayCfg });
272
342
  done('bootstrap');
273
343
 
274
344
  begin('connect');
@@ -293,14 +363,18 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
293
363
  ? `not readable without credentials: ${unreachable.join(', ')}`
294
364
  : 'the public surface is reachable');
295
365
 
366
+ // A fronted identity resolves at the Gateway, not the pod host.
367
+ const frontActor = gatewayCfg?.frontActor || agent.store.getConfig()?.gateway?.frontActor || null;
368
+ const gwHost = frontActor ? new URL(frontActor).host : null;
296
369
  run.result = {
297
370
  kind,
298
371
  pod,
299
372
  handle,
300
373
  actor: agent.urls?.actor || null,
301
- webfingerHost: wfHost,
302
- resolvable: !!wfHost,
303
- address: wfHost ? `@${handle}@${wfHost}` : null,
374
+ fronted: !!frontActor,
375
+ webfingerHost: gwHost || wfHost,
376
+ resolvable: !!(gwHost || wfHost),
377
+ address: gwHost ? `@${handle}@${gwHost}` : (wfHost ? `@${handle}@${wfHost}` : null),
304
378
  unreachable,
305
379
  };
306
380
  run.phase = 'done';
@@ -25,8 +25,9 @@ import * as podPolicy from '../pod/policy.mjs';
25
25
  // wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
26
26
  // friends), which a serverless front must never carry — it crashed the
27
27
  // deployed function before it answered its first request.
28
- const jrd = ({ handle, host, actor }) => ({
28
+ const jrd = ({ handle, host, actor, aliases = [] }) => ({
29
29
  subject: `acct:${handle}@${host}`,
30
+ ...(aliases.length ? { aliases } : {}),
30
31
  links: [{ rel: 'self', type: 'application/activity+json', href: actor }],
31
32
  });
32
33
 
@@ -580,7 +581,11 @@ async function route(request, ctx) {
580
581
  if (!m || m[2] !== ctx.host) return notFound();
581
582
  const rec = await ctx.lookup(m[1]);
582
583
  if (!rec) return notFound();
583
- return j(200, jrd({ handle: m[1], host: ctx.host, actor: rec.actorUrl }),
584
+ // A fronted identity's documents live on its pod; the pod's own actor id
585
+ // is the alias, so a client signing in by the fronted address can find
586
+ // the pod (and its login) without a lookup only the host could answer.
587
+ const podActor = rec.inboxOnly ? [] : [rec.podHome + 'ap/actor'];
588
+ return j(200, jrd({ handle: m[1], host: ctx.host, actor: rec.actorUrl, aliases: podActor }),
584
589
  'application/jrd+json');
585
590
  }
586
591
 
@@ -607,6 +612,13 @@ async function route(request, ctx) {
607
612
  // fixed to the front so a consumer cross-checks it consistently.
608
613
  if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: {}, body: '' };
609
614
  const podTarget = rec.podHome + up.rest;
615
+ // Media stays on the pod (lib/pod/urls.mjs keeps `media` off the front), but
616
+ // the id rewrite below turns media links onto the front like every other
617
+ // pod url in a document. Answer those by pointing at the pod: bytes are not
618
+ // a document to cap and relabel, and remotes follow a redirect for a picture.
619
+ if (up.rest.startsWith('ap/media/')) {
620
+ return { status: 302, headers: { location: podTarget, 'cache-control': 'no-store' }, body: '' };
621
+ }
610
622
  // The pod this read belongs to travels with it: an adapter reading a store
611
623
  // directly (the CSS server component) has no access control of its own and
612
624
  // needs to be told what it may reach. See podHomeProblem above for the other
package/lib/pod/root.mjs CHANGED
@@ -39,6 +39,33 @@ export async function readOwnerLinks(fetchImpl, podBase, { timeoutMs = OWNER_LOO
39
39
  *
40
40
  * ---- asked by: a provisioning client, about a pod the person brought ----
41
41
  */
42
+ /**
43
+ * Where a provider puts its pods: on hosts of their own, or on paths of one
44
+ * suffix-based host. No spec says. What does say is the storage description at the
45
+ * provider's root: a CSS that keeps pods on subdomains answers 501 there,
46
+ * because its root is not a storage; one that keeps them on paths answers 200
47
+ * with the root described as a storage. Anything else is unknown.
48
+ *
49
+ * Decides, at sign-up, whether the address can live on the pod at all: a pod
50
+ * on a path shares its host, so nothing there answers WebFinger for it.
51
+ *
52
+ * @returns 'host' | 'path' | null
53
+ */
54
+ export async function podLayout(fetchImpl, providerOrigin, { timeoutMs = OWNER_LOOKUP_MS } = {}) {
55
+ let origin;
56
+ try { origin = new URL(providerOrigin).origin; } catch { return null; }
57
+ let res;
58
+ try {
59
+ res = await fetchImpl(`${origin}/.well-known/solid`,
60
+ { headers: { accept: 'text/turtle' }, signal: AbortSignal.timeout(timeoutMs) });
61
+ } catch { return null; }
62
+ if (res.status === 501) return 'host';
63
+ if (res.status !== 200) return null;
64
+ let body = '';
65
+ try { body = await readCapped(res, 64 * 1024); } catch { return null; }
66
+ return /ns\/pim\/space#Storage|pim:Storage/u.test(body) ? 'path' : null;
67
+ }
68
+
42
69
  export async function probeAnswers(podUrl, fetchImpl = fetch) {
43
70
  try {
44
71
  const res = await fetchImpl(podUrl, { method: 'HEAD' });
@@ -334,9 +334,14 @@ export class PodTransport {
334
334
  }
335
335
 
336
336
  async setAcl(targetUrl, publicModes, opts = {}) {
337
- const url = await this.aclUrlFor(targetUrl);
337
+ // The rule names the resource on the POD. A fronted identity hands in
338
+ // advertised urls; `fetch` maps the request, but a rule whose accessTo
339
+ // named the advertised url would guard a resource the pod does not have,
340
+ // and lock the real one to nobody — the owner included.
341
+ const podTarget = this.toPod ? this.toPod(targetUrl) : targetUrl;
342
+ const url = await this.aclUrlFor(podTarget);
338
343
  if (!await this.aclWritable(url)) return null;
339
- return this.put(url, this.aclDoc(targetUrl, publicModes, { ...opts, aclUrl: url }), 'text/turtle');
344
+ return this.put(url, this.aclDoc(podTarget, publicModes, { ...opts, aclUrl: url }), 'text/turtle');
340
345
  }
341
346
 
342
347
  // Child documents of an LDP container (URLs under it, excluding aux docs).
package/lib/pod/urls.mjs CHANGED
@@ -65,6 +65,20 @@ export function apUrls(remotePod, root, { publicBase = null } = {}) {
65
65
  // here. @name@host is looked up at https://host/.well-known/webfinger, so only
66
66
  // a pod that owns the root of its host can answer for one; a pod living at
67
67
  // https://server/name/ may publish the document but nothing will ever ask.
68
+ /**
69
+ * The pod a WebID lives in: the URL up to the profile document's container.
70
+ * `https://alice.pod/profile/card#me` → `https://alice.pod/`, and on a shared
71
+ * host `https://server/alice/profile/card#me` → `https://server/alice/`. Taking
72
+ * the origin alone named the wrong pod for the second, which is every pod on a
73
+ * path.
74
+ */
75
+ export function podBaseOfWebId(webId) {
76
+ const u = new URL(webId);
77
+ u.hash = ''; u.search = '';
78
+ const dir = u.pathname.replace(/profile\/card$/u, '').replace(/[^/]*$/u, '');
79
+ return `${u.origin}${dir.endsWith('/') ? dir : dir + '/'}`;
80
+ }
81
+
68
82
  export function webfingerHost(podUrl) {
69
83
  const u = new URL(podUrl);
70
84
  return u.pathname === '/' ? u.host : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod-server",
3
- "version": "0.14.1",
3
+ "version": "0.16.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",
@@ -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>
@@ -93,12 +93,18 @@ function answers() {
93
93
  const f = $('form').elements;
94
94
  const kind = f.kind.value;
95
95
  const mode = state.resumable ? 'existing' : f.mode.value;
96
+ const issuer = mode === 'new'
97
+ ? (f.issuerNew.value || (f.issuerOther ? f.issuerOther.value : '')).trim()
98
+ : f.issuer.value.trim();
96
99
  const a = {
97
100
  kind,
98
101
  mode,
99
102
  handle: f.handle.value.trim(),
100
- issuer: (mode === 'new' ? f.issuerNew.value : f.issuer.value).trim(),
103
+ issuer,
101
104
  email: f.email.value.trim(),
105
+ // Where the address lives; a group cannot front (yet), so it stays 'pod'.
106
+ shape: (kind === 'group') ? 'pod' : (f.shape ? f.shape.value : 'pod'),
107
+ gatewayOrigin: (f.gatewayOrigin ? f.gatewayOrigin.value.trim() : '') || 'https://fedipod.net',
102
108
  };
103
109
  if (mode === 'new') a.podName = f.podName.value.trim() || a.handle;
104
110
  else a.pod = f.pod.value.trim();
@@ -128,6 +134,9 @@ function onEdit() {
128
134
  $('row-pod').hidden = mode === 'new';
129
135
  $('row-issuer-new').hidden = mode !== 'new';
130
136
  $('row-issuer-existing').hidden = mode === 'new';
137
+ const f = $('form').elements;
138
+ if ($('row-issuer-other')) $('row-issuer-other').hidden = !(mode === 'new' && f.issuerNew.value === '');
139
+ if ($('row-gateway')) $('row-gateway').hidden = !(f.shape && f.shape.value === 'front');
131
140
  clearTimeout(editTimer);
132
141
  editTimer = setTimeout(preview, 150);
133
142
  }
@@ -140,6 +149,26 @@ async function preview() {
140
149
  const { json } = await postJson('/setup/check', a);
141
150
  if (!json) return;
142
151
  $('preview').textContent = json.address || '…';
152
+ // The address-shape choice: hidden for a group (a group cannot front yet),
153
+ // locked to the gateway for a pod on a suffix-based host, an open choice
154
+ // for a pod at its own host.
155
+ const f = $('form').elements;
156
+ const shapeFs = $('fs-shape');
157
+ if (shapeFs) {
158
+ const isGroup = a.kind === 'group';
159
+ shapeFs.hidden = isGroup || (a.mode === 'existing' && !a.pod);
160
+ const forced = !!json.forced;
161
+ for (const r of f.shape) r.disabled = forced;
162
+ if (forced) { for (const r of f.shape) r.checked = r.value === 'front'; }
163
+ if ($('row-gateway')) $('row-gateway').hidden = !(f.shape.value === 'front');
164
+ const note = $('shape-note');
165
+ if (note) {
166
+ note.hidden = !forced;
167
+ note.textContent = forced
168
+ ? 'Your pod is on a suffix-based host, so its address lives at the gateway. Your posts, key and data stay on your pod.'
169
+ : '';
170
+ }
171
+ }
143
172
  const notes = $('preview-notes');
144
173
  for (const w of json.warnings || []) {
145
174
  const p = document.createElement('p');