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/web/app/README.md CHANGED
@@ -72,6 +72,6 @@ See the `MastoApi` options in `agent.mjs`.
72
72
  | **Streaming** | a service worker answers fetches, not sockets. No streaming URL is advertised, so clients poll. |
73
73
  | **Web push** | `shims/web-push.mjs` is a no-op. `vapid` is omitted, and a client that subscribes anyway gets a 422 rather than a subscription nothing will push to. |
74
74
  | **Scheduled posts** | nothing runs between now and the scheduled time. A `scheduled_at` is refused with a 422 that says so — accepting one was silent loss. |
75
- | **Groups** | sign-up makes personal identities only (`signup.mjs`), and the moderation surface is not here. Joining a group works; hosting one needs the installed agent. See `groups.md`. |
76
- | **A fronted `@you@front` handle** | the browser model is `@you@yourpod` with the gateway as a mail door. `admin-facade.mjs` refuses a fronted attach, and `stage-site.mjs` hides the radio that offered it. |
75
+ | **Groups** | sign-up makes personal identities only (`signup.mjs`), and the moderation surface is not here. Joining a group works; hosting one needs the DeviceAgent. See `groups.md`. |
76
+ | **Changing where the address lives** | the shape is chosen at sign-up (`signup.mjs`): on the pod, `@you@yourpod` with the gateway as a mail door, or at the gateway, `@you@front` — and a pod on a suffix-based host is always fronted. `admin-facade.mjs` refuses changing it afterwards, because a rename needs a restart a browser does not have. |
77
77
  | **Moving the private half** | `/state-move` is about filesystem paths and `credential.json`. A browser has neither; its private half is always on the pod. |
@@ -317,7 +317,7 @@ export class AdminFacade {
317
317
  const aliases = [...(cfg.aliases || [])];
318
318
  if (body.add) {
319
319
  if (!webfingerHost(urls.base) && !cfg.gateway?.frontActor) {
320
- return json(400, { error: 'this pod is a path on a shared host, so other servers could never resolve it as a Move target' });
320
+ return json(400, { error: 'this pod is a suffix-based host, so other servers could never resolve it as a Move target' });
321
321
  }
322
322
  const id = await this.resolveActor(body.add);
323
323
  if (!id) return json(400, { error: `could not fetch the old account (${body.add}) — enter its URL or @user@host, and it must answer` });
package/web/app/agent.mjs CHANGED
@@ -17,7 +17,7 @@ import { MastoApi } from '../../lib/client/masto/index.mjs';
17
17
  import { TagFeed } from '../../lib/connections/tagfeed.mjs';
18
18
  import { makeDpopSession } from './pod-auth.mjs';
19
19
  import { BrowserRemotePod } from './pod-remote.mjs';
20
- import { importSigningKey, loadKeysFromPod, cacheOpenedKeys } from './keys-browser.mjs';
20
+ import { importSigningKey, loadKeysFromPod, cacheOpenedKeys, podActorOf } from './keys-browser.mjs';
21
21
  import { generateKeys, wrapKeys } from './keystore.mjs';
22
22
  import { RelayDeliverer, doorKeyOf } from './deliver-relay.mjs';
23
23
  import { AdminFacade } from './admin-facade.mjs';
@@ -26,6 +26,7 @@ import { BskyFeed } from '../../lib/connections/bskyfeed.mjs';
26
26
  import { BrowserFediAccounts } from './fediacct-browser.mjs';
27
27
  import { AcctFeed } from '../../lib/connections/acctfeed.mjs';
28
28
  import { followActor, unfollowActor, resolveHandle } from '../../lib/core/social.mjs';
29
+ import { podBaseOfWebId } from '../../lib/pod/urls.mjs';
29
30
  import { ImportWorker } from '../../lib/connections/import.mjs';
30
31
 
31
32
  // The authorities this identity answers on: exactly one, this origin. The Node
@@ -159,7 +160,7 @@ export class BrowserAgent {
159
160
  if (oidc) {
160
161
  session = { fetch: (u, i) => oidc.fetch(u, i) };
161
162
  webId = oidc.webId;
162
- remotePod = new URL(webId).origin + '/';
163
+ remotePod = podBaseOfWebId(webId); // the pod, which on a suffix-based host is a path
163
164
  } else {
164
165
  const dpop = await makeDpopSession(credential);
165
166
  session = { fetch: (u, i) => dpop.fetch(u, i) };
@@ -169,6 +170,8 @@ export class BrowserAgent {
169
170
  this.webId = webId;
170
171
  const root = (config && config.root) || 'fedipod/';
171
172
  this.remote = new BrowserRemotePod(session, { webId, log: this.log });
173
+ // Pod-native for now: the state store below is read with these, and only
174
+ // the config it holds says whether this identity is fronted.
172
175
  this.urls = apUrls(remotePod, root);
173
176
 
174
177
  // State store, on the pod.
@@ -189,9 +192,18 @@ export class BrowserAgent {
189
192
  // document it published landed under `activitypods-js/`. One root, decided
190
193
  // once, carried by the config everything downstream reads.
191
194
  this.store.setConfig({ ...(this.store.getConfig() || {}), ...cfg, root });
195
+ config = this.store.getConfig();
196
+ // A fronted identity (config.gateway.frontActor) advertises its ids at the
197
+ // gateway; the documents stay on the pod. Same three lines as the Node
198
+ // agent (run-agent.mjs connect): the advertised urls, and the map that
199
+ // turns an advertised url back into the pod one at the transport's single
200
+ // write and read choke point. State and media stay pod-native either way.
201
+ const publicBase = config.gateway?.frontActor
202
+ ? config.gateway.frontActor.replace(/ap\/actor\/?$/, '') : null;
203
+ this.urls = apUrls(remotePod, root, { publicBase });
204
+ if (this.urls.toPod) this.remote.setUrlMap(this.urls.toPod);
192
205
  // Keys: handed in (offline), or the owner-only keys.json read from the pod.
193
206
  const keys = keysRecord ? await importSigningKey(keysRecord) : await loadKeysFromPod(this.remote, this.urls);
194
- config = this.store.getConfig();
195
207
 
196
208
 
197
209
  // `passive`: no queue-drain timer until this device is the active one.
@@ -212,7 +224,7 @@ export class BrowserAgent {
212
224
  this.publisher = new Publisher({
213
225
  config: this.store.getConfig(), remote: this.remote, store: this.store,
214
226
  deliverer: this.deliverer, publicKeyPem: keys.rsaPublicPem, assertionKey: null, log: this.log,
215
- // Who a post names, resolved — the same lookup the installed agent
227
+ // Who a post names, resolved — the same lookup the DeviceAgent
216
228
  // gives its publisher. Without it no mention from the browser ever
217
229
  // resolved: a direct message went to nobody, a mention notified no one.
218
230
  resolveMention: (h) => resolveHandle(this, h),
@@ -370,7 +382,7 @@ export class BrowserAgent {
370
382
  const rec = await generateKeys();
371
383
  rec.mintedFor = this.urls.actor; // one key, one actor (lib/keys.mjs)
372
384
  await podState.writeWrappedKeys(this.remote, this.urls, await wrapKeys(rec, password));
373
- const keys = await cacheOpenedKeys(this.urls.actor, rec);
385
+ const keys = await cacheOpenedKeys(podActorOf(this.urls), rec);
374
386
  this.publisher.publicKeyPem = keys.rsaPublicPem;
375
387
  this.deliverer.rsaPrivate = keys.rsaPrivate;
376
388
  await this.publisher.publishProfile();
package/web/app/boot.mjs CHANGED
@@ -12,6 +12,8 @@
12
12
  import { signUp, handleProblem, AP_ROOT } from './signup.mjs';
13
13
  import * as podActor from '../../lib/pod/actor.mjs';
14
14
  import * as podState from '../../lib/pod/state.mjs';
15
+ import { podBaseOfWebId } from '../../lib/pod/urls.mjs';
16
+ import { podLayout } from '../../lib/pod/root.mjs';
15
17
  import { BrowserRemotePod } from './pod-remote.mjs';
16
18
  import { beginLogin, completeLogin, getSession, signOut } from './oidc-session.mjs';
17
19
  import { unwrapKeys, isKeyEnvelope } from './keystore.mjs';
@@ -68,7 +70,7 @@ window.fedipodUnlock = async (password) => {
68
70
  if (!session) throw new Error('Sign in first.');
69
71
  // The config on the pod says where this account's state lives; the key sits
70
72
  // beside it. Both are read with the session, as the owner.
71
- const podFromWebId = new URL(session.webId).origin + '/';
73
+ const podFromWebId = podBaseOfWebId(session.webId); // a suffix-based host, or its own host
72
74
  const state = `${podFromWebId}${AP_ROOT}ap-state/`;
73
75
  // Through the transport rather than the bare session: this is a pod read
74
76
  // like any other, and going round it skipped the retry ladder that exists
@@ -112,21 +114,38 @@ export function parseAddress(input) {
112
114
  }
113
115
  async function issuerForPod(pod) {
114
116
  // The pod's actor says where a client signs in (oauthAuthorizationEndpoint's
115
- // origin); failing that, the account provider is the pod host's parent domain.
117
+ // origin); failing that, the account provider is the pod host's parent
118
+ // domain for a subdomain pod, and the host itself for a pod on a path.
116
119
  try {
117
120
  const authz = await podActor.readIssuer(`${pod}${AP_ROOT}ap/actor`);
118
121
  if (authz) return new URL(authz).origin;
119
122
  } catch { /* fall through */ }
120
- const host = new URL(pod).host;
121
- const parent = host.split('.').slice(1).join('.');
122
- return `https://${parent || host}`;
123
+ const u = new URL(pod);
124
+ if (u.pathname !== '/') return u.origin;
125
+ const parent = u.host.split('.').slice(1).join('.');
126
+ return `https://${parent || u.host}`;
127
+ }
128
+ // An address at this site names a fronted identity. Its documents live on a
129
+ // pod this site's WebFinger names as an alias — the pod's own actor id.
130
+ async function podForFrontedAddress(handle) {
131
+ const res = await fetch(`/.well-known/webfinger?resource=${encodeURIComponent(`acct:${handle}@${location.host}`)}`,
132
+ { headers: { accept: 'application/jrd+json, application/json' } }).catch(() => null);
133
+ if (!res || res.status >= 400) throw new Error(`nobody at this site is called @${handle}@${location.host}`);
134
+ const doc = await res.json().catch(() => ({}));
135
+ const podActorId = (doc.aliases || []).find((a) => /\/ap\/actor$/u.test(String(a)));
136
+ if (!podActorId) throw new Error(`@${handle}@${location.host} lives here but names no pod to sign in to`);
137
+ const tail = `${AP_ROOT}ap/actor`;
138
+ if (!podActorId.endsWith(tail)) throw new Error(`the pod actor ${podActorId} is not where a FediPod pod keeps one`);
139
+ return podActorId.slice(0, -tail.length);
123
140
  }
124
141
  window.fedipodSignin = async ({ address }) => {
125
142
  const parsed = parseAddress(address);
126
143
  if (!parsed) throw new Error('Enter your address as @you@yourpod (for example @alice@alice.solidcommunity.net).');
127
144
  const bad = handleProblem(parsed.handle);
128
145
  if (bad) throw new Error(bad);
129
- const pod = `https://${parsed.host}/`;
146
+ const pod = parsed.host === location.host.toLowerCase()
147
+ ? await podForFrontedAddress(parsed.handle)
148
+ : `https://${parsed.host}/`;
130
149
  const issuer = await issuerForPod(pod);
131
150
  const { authorizationUrl } = await beginLogin({ issuer, redirectUri: REDIRECT });
132
151
  location.href = authorizationUrl;
@@ -262,24 +281,61 @@ if (typeof document !== 'undefined') (async () => {
262
281
  // --- register form: two screens, pod first then Fediverse identity ---
263
282
  const f = () => $('form').elements;
264
283
  // The pod provider is a free-text URL; default and normalise to a scheme.
265
- const providerUrl = () => { let v = f().provider.value.trim(); if (!v) v = 'https://solidcommunity.net'; if (!/^https?:\/\//i.test(v)) v = 'https://' + v; return v; };
284
+ // The provider is picked from the list, or typed under "Other…".
285
+ const providerUrl = () => { let v = (f().provider.value || f().providerOther.value).trim(); if (!v) return ''; if (!/^https?:\/\//i.test(v)) v = 'https://' + v; return v; };
266
286
  const providerHost = () => { try { return new URL(providerUrl()).host; } catch { return ''; } };
267
- // The pod is always a subdomain of the provider, so both new and existing pods
268
- // resolve to https://<subdomain>.<provider-host>/ and carry a host-root address.
287
+ // A new pod is named by its subdomain under the provider; an existing pod is
288
+ // brought by its address, which may be its own host or a path on a shared one.
269
289
  const podHostOf = () => { const sub = f().podName.value.trim().toLowerCase(); const ph = providerHost(); return (sub && ph) ? `${sub}.${ph}` : ''; };
290
+ const podUrl = () => { let v = f().pod.value.trim(); if (!v) return ''; if (!/^https?:\/\//i.test(v)) v = 'https://' + v; if (!v.endsWith('/')) v += '/'; try { return new URL(v).href; } catch { return ''; } };
291
+ // A pod on a suffix-based host cannot answer WebFinger, so its address
292
+ // lives at this site; a pod at its own host root gets the choice.
293
+ const isPathPod = (u) => { try { return new URL(u).pathname !== '/'; } catch { return false; } };
294
+ // Where the chosen provider puts new pods, asked of the provider itself
295
+ // (lib/pod/root.mjs podLayout) and remembered per provider: 'host', 'path',
296
+ // or null when it would not say.
297
+ const layouts = new Map();
298
+ let layout = null;
299
+ const learnLayout = async () => {
300
+ const origin = providerHost() ? new URL(providerUrl()).origin : '';
301
+ if (!origin) { layout = null; return; }
302
+ if (!layouts.has(origin)) layouts.set(origin, podLayout(fetch, origin).catch(() => null));
303
+ const known = await layouts.get(origin);
304
+ if (providerHost() && new URL(providerUrl()).origin === origin) { layout = known; applyShape(); previewAddr(); }
305
+ };
306
+ const pathPod = () => (f().mode.value === 'existing' ? isPathPod(podUrl()) : layout === 'path');
307
+ const shape = () => (pathPod() ? 'front' : f().shape.value);
270
308
  const answers = () => {
271
309
  const mode = f().mode.value;
272
310
  const a = { mode, handle: f().handle.value.trim().toLowerCase(), email: f().email.value.trim(),
273
- password: f().password.value, issuer: providerUrl() };
311
+ password: f().password.value, issuer: providerUrl(), shape: shape() };
274
312
  if (mode === 'new') a.podName = f().podName.value.trim().toLowerCase();
275
- else a.pod = `https://${podHostOf()}/`;
313
+ else a.pod = podUrl();
276
314
  return a;
277
315
  };
278
316
  const previewAddr = () => {
279
- const handle = f().handle.value.trim().toLowerCase(); const ph = podHostOf();
280
- $('preview').textContent = (handle && ph) ? `@${handle}@${ph}` : '@…@…';
317
+ const handle = f().handle.value.trim().toLowerCase();
318
+ const host = shape() === 'front' ? location.host
319
+ : (f().mode.value === 'existing' ? (podUrl() ? new URL(podUrl()).host : '') : podHostOf());
320
+ $('preview').textContent = (handle && host) ? `@${handle}@${host}` : '@…@…';
321
+ };
322
+ // The shape choice is fixed for a path pod, and open for a host-root pod.
323
+ // A path pod has no choice to make: the radios go away and the note says why.
324
+ const applyShape = () => {
325
+ const fixed = pathPod();
326
+ for (const r of f().shape) { if (fixed) r.checked = r.value === 'front'; }
327
+ $('shape-group').hidden = fixed;
328
+ $('shape-hint').hidden = !fixed;
281
329
  };
282
- for (const el of $('form').elements) el.addEventListener('input', previewAddr);
330
+ const applyMode = () => {
331
+ const existing = f().mode.value === 'existing';
332
+ $('pod-field').hidden = !existing;
333
+ $('podname-field').hidden = existing;
334
+ $('provider-other-field').hidden = f().provider.value !== '';
335
+ };
336
+ for (const el of $('form').elements) for (const evt of ['input', 'change']) el.addEventListener(evt, () => { applyMode(); applyShape(); previewAddr(); });
337
+ for (const evt of ['input', 'change']) { $('provider').addEventListener(evt, learnLayout); $('providerOther').addEventListener(evt, learnLayout); }
338
+ applyMode(); learnLayout();
283
339
 
284
340
  // Step machine: one screen at a time, each gated by its own validation.
285
341
  const STEP_IDS = ['step-1', 'step-2'];
@@ -287,15 +343,19 @@ if (typeof document !== 'undefined') (async () => {
287
343
  const goStep = (n) => {
288
344
  STEP_IDS.forEach((id, i) => { $(id).hidden = i !== n - 1; });
289
345
  $('err-1').textContent = ''; $('form-error').textContent = '';
290
- if (n === 2) previewAddr();
346
+ if (n === 2) { applyShape(); previewAddr(); learnLayout(); }
291
347
  if (FOCUS[n]) $(FOCUS[n]).focus();
292
348
  };
293
349
  const validateStep1 = () => {
294
- if (!providerHost()) return 'A valid pod provider URL is required.';
295
- const sub = f().podName.value.trim().toLowerCase();
296
- if (!sub) return 'A pod username/subdomain is required.';
297
- const sp = window.fedipodHandleProblem(sub);
298
- if (sp) return `Pod username: ${sp}`;
350
+ if (!providerHost()) return f().provider.value === '' ? 'A pod provider address is required under Other….' : 'A valid pod provider URL is required.';
351
+ if (f().mode.value === 'existing') {
352
+ if (!podUrl()) return 'A pod address is required, like https://alice.solidcommunity.net/ or https://server.example/alice/.';
353
+ } else {
354
+ const sub = f().podName.value.trim().toLowerCase();
355
+ if (!sub) return 'A pod username/subdomain is required.';
356
+ const sp = window.fedipodHandleProblem(sub);
357
+ if (sp) return `Pod username: ${sp}`;
358
+ }
299
359
  if (!f().email.value.trim()) return 'A pod email is required.';
300
360
  if (!f().password.value) return 'A pod password is required.';
301
361
  return null;
@@ -33025,9 +33025,10 @@ var PodTransport = class {
33025
33025
  return serialize(doc, g, url, "text/turtle");
33026
33026
  }
33027
33027
  async setAcl(targetUrl, publicModes, opts = {}) {
33028
- const url = await this.aclUrlFor(targetUrl);
33028
+ const podTarget = this.toPod ? this.toPod(targetUrl) : targetUrl;
33029
+ const url = await this.aclUrlFor(podTarget);
33029
33030
  if (!await this.aclWritable(url)) return null;
33030
- return this.put(url, this.aclDoc(targetUrl, publicModes, { ...opts, aclUrl: url }), "text/turtle");
33031
+ return this.put(url, this.aclDoc(podTarget, publicModes, { ...opts, aclUrl: url }), "text/turtle");
33031
33032
  }
33032
33033
  // Child documents of an LDP container (URLs under it, excluding aux docs).
33033
33034
  // Revalidated: the inbox is polled every couple of minutes and is usually
@@ -33266,6 +33267,15 @@ function handleProblem(handle) {
33266
33267
  }
33267
33268
  var PROGRESS = /* @__PURE__ */ new Map();
33268
33269
  var progressKey = (a) => [a.issuer, a.mode, a.handle, a.mode === "new" ? a.podName || a.handle : a.pod].join("|");
33270
+ async function assertFrontNameFree(frontOrigin, handle) {
33271
+ const res = await fetch(
33272
+ `${frontOrigin.replace(/\/$/, "")}/api/handle?handle=${encodeURIComponent(handle)}`,
33273
+ { headers: { accept: "application/json" } }
33274
+ ).catch(() => null);
33275
+ const d = res ? await res.json().catch(() => ({})) : null;
33276
+ if (!d) throw new Error(`${new URL(frontOrigin).host} did not answer whether @${handle} is free`);
33277
+ if (!d.available) throw new Error(d.reason || `the name @${handle}@${new URL(frontOrigin).host} is taken \u2014 choose another handle`);
33278
+ }
33269
33279
  async function signUp(answers, { onStep = () => {
33270
33280
  }, frontOrigin = null } = {}) {
33271
33281
  const { mode, issuer, email, password, handle } = answers;
@@ -33274,6 +33284,9 @@ async function signUp(answers, { onStep = () => {
33274
33284
  if (!email) throw new Error("an email is required");
33275
33285
  if (!password) throw new Error("a password is required");
33276
33286
  if (mode === "existing" && !answers.pod) throw new Error("a pod address is required");
33287
+ const wantsFront = answers.shape === "front";
33288
+ if (wantsFront && !frontOrigin) throw new Error("an address at the gateway needs a gateway, and this page has none");
33289
+ if (wantsFront) await assertFrontNameFree(frontOrigin, handle);
33277
33290
  const key = progressKey(answers);
33278
33291
  const prog = PROGRESS.get(key) || {};
33279
33292
  PROGRESS.set(key, prog);
@@ -33305,11 +33318,14 @@ async function signUp(answers, { onStep = () => {
33305
33318
  }
33306
33319
  const pod = prog.pod;
33307
33320
  const webId = prog.webId || null;
33308
- const podUrl = new URL(pod);
33309
- if (podUrl.pathname !== "/") {
33310
- throw new Error(`${pod} is a path on ${podUrl.host}, not its own host. A Fediverse address lives at a host root, so this pod cannot carry one. Use a pod that is the root of its own subdomain.`);
33321
+ const pathPod = new URL(pod).pathname !== "/";
33322
+ const fronted = pathPod || wantsFront;
33323
+ if (fronted && !frontOrigin) {
33324
+ throw new Error(`${pod} is a suffix-based host, so its address must live at a gateway, and this page has none.`);
33311
33325
  }
33326
+ if (pathPod && !wantsFront) await assertFrontNameFree(frontOrigin, handle);
33312
33327
  const actorUrl = actorUrlFor(pod);
33328
+ const frontActor = fronted ? `${frontOrigin.replace(/\/$/, "")}/u/${handle}/ap/actor` : null;
33313
33329
  const cred = step("credential");
33314
33330
  let credential;
33315
33331
  if (!prog.credential) {
@@ -33324,12 +33340,36 @@ async function signUp(answers, { onStep = () => {
33324
33340
  cred.ok();
33325
33341
  }
33326
33342
  const session = await makeDpopSession(credential);
33343
+ let gateway = answers.gateway || prog.gateway || null;
33344
+ if (frontOrigin && !gateway) {
33345
+ const gw = step("gateway");
33346
+ gw.running(fronted ? `taking your address at ${new URL(frontOrigin).host}` : `connecting your mail door on ${new URL(frontOrigin).host}`);
33347
+ const res = await session.fetch(`${frontOrigin.replace(/\/$/, "")}/api/attach`, {
33348
+ method: "POST",
33349
+ headers: { "content-type": "application/json" },
33350
+ // podHome is the AP CONTAINER, not the pod root: the front builds the
33351
+ // delivery target as `podHome + 'ap/inbox/'` (lib/front-core.mjs), so a
33352
+ // bare pod root sends this identity's mail to <pod>/ap/inbox/ — outside
33353
+ // the container the agent drains, where nothing would ever read it. The
33354
+ // manage surface has always sent `urls.home`; this is the same value.
33355
+ body: JSON.stringify({ handle, podHome: `${pod}${AP_ROOT}`, actorUrl, kind: "person", fronted })
33356
+ });
33357
+ const d = await res.json().catch(() => ({}));
33358
+ if (res.status !== 201 || !d.hmacSecret) {
33359
+ throw new Error(`could not connect the gateway (HTTP ${res.status}): ${d.error || ""}`);
33360
+ }
33361
+ gateway = fronted ? { url: `${frontOrigin.replace(/\/$/, "")}/u/${handle}/ap/inbox/`, frontActor: String(d.frontActor || frontActor), hmacSecret: d.hmacSecret, mode: "trust" } : { url: d.doorInbox, hmacSecret: d.hmacSecret, mode: "trust" };
33362
+ prog.gateway = gateway;
33363
+ gw.ok();
33364
+ } else if (frontOrigin && gateway) {
33365
+ step("gateway").ok();
33366
+ }
33327
33367
  const keysStep = step("keys");
33328
33368
  let keys;
33329
33369
  if (!prog.keysStored) {
33330
33370
  keysStep.running("making your signing key and locking it under your password");
33331
33371
  keys = await generateKeys();
33332
- keys.mintedFor = actorUrl;
33372
+ keys.mintedFor = gateway?.frontActor || actorUrl;
33333
33373
  const remote = new BrowserRemotePod(session, { webId: credential.webId, role: "signup", log: () => {
33334
33374
  } });
33335
33375
  try {
@@ -33349,30 +33389,6 @@ async function signUp(answers, { onStep = () => {
33349
33389
  keys = prog.keys;
33350
33390
  keysStep.ok();
33351
33391
  }
33352
- let gateway = answers.gateway || prog.gateway || null;
33353
- if (frontOrigin && !gateway) {
33354
- const gw = step("gateway");
33355
- gw.running(`connecting your mail door on ${new URL(frontOrigin).host}`);
33356
- const res = await session.fetch(`${frontOrigin.replace(/\/$/, "")}/api/attach`, {
33357
- method: "POST",
33358
- headers: { "content-type": "application/json" },
33359
- // podHome is the AP CONTAINER, not the pod root: the front builds the
33360
- // delivery target as `podHome + 'ap/inbox/'` (lib/front-core.mjs), so a
33361
- // bare pod root sends this identity's mail to <pod>/ap/inbox/ — outside
33362
- // the container the agent drains, where nothing would ever read it. The
33363
- // manage surface has always sent `urls.home`; this is the same value.
33364
- body: JSON.stringify({ handle, podHome: `${pod}${AP_ROOT}`, actorUrl, kind: "person" })
33365
- });
33366
- const d = await res.json().catch(() => ({}));
33367
- if (res.status !== 201 || !d.hmacSecret) {
33368
- throw new Error(`could not connect the mail door (HTTP ${res.status}): ${d.error || ""}`);
33369
- }
33370
- gateway = { url: d.doorInbox, hmacSecret: d.hmacSecret, mode: "trust" };
33371
- prog.gateway = gateway;
33372
- gw.ok();
33373
- } else if (frontOrigin && gateway) {
33374
- step("gateway").ok();
33375
- }
33376
33392
  const config = {
33377
33393
  remotePod: pod,
33378
33394
  root: AP_ROOT,
@@ -33397,7 +33413,7 @@ async function signUp(answers, { onStep = () => {
33397
33413
  onStep("credential", "ok", "this browser is ready (the setup credential could not be revoked automatically \u2014 you can remove it from your pod's account page)");
33398
33414
  }
33399
33415
  PROGRESS.delete(key);
33400
- const host = new URL(pod).host;
33416
+ const host = gateway?.frontActor ? new URL(gateway.frontActor).host : new URL(pod).host;
33401
33417
  return {
33402
33418
  credential,
33403
33419
  config,
@@ -33425,6 +33441,45 @@ async function readIssuer(actorUrl, fetchImpl = fetch) {
33425
33441
  }
33426
33442
  }
33427
33443
 
33444
+ // lib/pod/urls.mjs
33445
+ function podBaseOfWebId(webId) {
33446
+ const u = new URL(webId);
33447
+ u.hash = "";
33448
+ u.search = "";
33449
+ const dir = u.pathname.replace(/profile\/card$/u, "").replace(/[^/]*$/u, "");
33450
+ return `${u.origin}${dir.endsWith("/") ? dir : dir + "/"}`;
33451
+ }
33452
+
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
+
33428
33483
  // web/app/oidc-session.mjs
33429
33484
  var DB2 = "fedipod-oidc";
33430
33485
  var STORE = "session";
@@ -33674,7 +33729,7 @@ window.fedipodUnlock = async (password) => {
33674
33729
  if (!password) throw new Error("Enter your account password.");
33675
33730
  const session = await getSession();
33676
33731
  if (!session) throw new Error("Sign in first.");
33677
- const podFromWebId = new URL(session.webId).origin + "/";
33732
+ const podFromWebId = podBaseOfWebId(session.webId);
33678
33733
  const state = `${podFromWebId}${AP_ROOT}ap-state/`;
33679
33734
  const remote = new BrowserRemotePod(session, { webId: session.webId, role: "signup", log: () => {
33680
33735
  } });
@@ -33711,16 +33766,30 @@ async function issuerForPod(pod) {
33711
33766
  if (authz) return new URL(authz).origin;
33712
33767
  } catch {
33713
33768
  }
33714
- const host = new URL(pod).host;
33715
- const parent = host.split(".").slice(1).join(".");
33716
- return `https://${parent || host}`;
33769
+ const u = new URL(pod);
33770
+ if (u.pathname !== "/") return u.origin;
33771
+ const parent = u.host.split(".").slice(1).join(".");
33772
+ return `https://${parent || u.host}`;
33773
+ }
33774
+ async function podForFrontedAddress(handle) {
33775
+ const res = await fetch(
33776
+ `/.well-known/webfinger?resource=${encodeURIComponent(`acct:${handle}@${location.host}`)}`,
33777
+ { headers: { accept: "application/jrd+json, application/json" } }
33778
+ ).catch(() => null);
33779
+ if (!res || res.status >= 400) throw new Error(`nobody at this site is called @${handle}@${location.host}`);
33780
+ const doc = await res.json().catch(() => ({}));
33781
+ const podActorId = (doc.aliases || []).find((a) => /\/ap\/actor$/u.test(String(a)));
33782
+ if (!podActorId) throw new Error(`@${handle}@${location.host} lives here but names no pod to sign in to`);
33783
+ const tail = `${AP_ROOT}ap/actor`;
33784
+ if (!podActorId.endsWith(tail)) throw new Error(`the pod actor ${podActorId} is not where a FediPod pod keeps one`);
33785
+ return podActorId.slice(0, -tail.length);
33717
33786
  }
33718
33787
  window.fedipodSignin = async ({ address }) => {
33719
33788
  const parsed = parseAddress(address);
33720
33789
  if (!parsed) throw new Error("Enter your address as @you@yourpod (for example @alice@alice.solidcommunity.net).");
33721
33790
  const bad = handleProblem(parsed.handle);
33722
33791
  if (bad) throw new Error(bad);
33723
- const pod = `https://${parsed.host}/`;
33792
+ const pod = parsed.host === location.host.toLowerCase() ? await podForFrontedAddress(parsed.handle) : `https://${parsed.host}/`;
33724
33793
  const issuer = await issuerForPod(pod);
33725
33794
  const { authorizationUrl } = await beginLogin({ issuer, redirectUri: REDIRECT });
33726
33795
  location.href = authorizationUrl;
@@ -33866,8 +33935,8 @@ ${e.detail}` : "");
33866
33935
  });
33867
33936
  const f = () => $("form").elements;
33868
33937
  const providerUrl = () => {
33869
- let v = f().provider.value.trim();
33870
- if (!v) v = "https://solidcommunity.net";
33938
+ let v = (f().provider.value || f().providerOther.value).trim();
33939
+ if (!v) return "";
33871
33940
  if (!/^https?:\/\//i.test(v)) v = "https://" + v;
33872
33941
  return v;
33873
33942
  };
@@ -33883,6 +33952,42 @@ ${e.detail}` : "");
33883
33952
  const ph = providerHost();
33884
33953
  return sub && ph ? `${sub}.${ph}` : "";
33885
33954
  };
33955
+ const podUrl = () => {
33956
+ let v = f().pod.value.trim();
33957
+ if (!v) return "";
33958
+ if (!/^https?:\/\//i.test(v)) v = "https://" + v;
33959
+ if (!v.endsWith("/")) v += "/";
33960
+ try {
33961
+ return new URL(v).href;
33962
+ } catch {
33963
+ return "";
33964
+ }
33965
+ };
33966
+ const isPathPod = (u) => {
33967
+ try {
33968
+ return new URL(u).pathname !== "/";
33969
+ } catch {
33970
+ return false;
33971
+ }
33972
+ };
33973
+ const layouts = /* @__PURE__ */ new Map();
33974
+ let layout = null;
33975
+ const learnLayout = async () => {
33976
+ const origin = providerHost() ? new URL(providerUrl()).origin : "";
33977
+ if (!origin) {
33978
+ layout = null;
33979
+ return;
33980
+ }
33981
+ if (!layouts.has(origin)) layouts.set(origin, podLayout(fetch, origin).catch(() => null));
33982
+ const known = await layouts.get(origin);
33983
+ if (providerHost() && new URL(providerUrl()).origin === origin) {
33984
+ layout = known;
33985
+ applyShape();
33986
+ previewAddr();
33987
+ }
33988
+ };
33989
+ const pathPod = () => f().mode.value === "existing" ? isPathPod(podUrl()) : layout === "path";
33990
+ const shape = () => pathPod() ? "front" : f().shape.value;
33886
33991
  const answers = () => {
33887
33992
  const mode = f().mode.value;
33888
33993
  const a = {
@@ -33890,18 +33995,43 @@ ${e.detail}` : "");
33890
33995
  handle: f().handle.value.trim().toLowerCase(),
33891
33996
  email: f().email.value.trim(),
33892
33997
  password: f().password.value,
33893
- issuer: providerUrl()
33998
+ issuer: providerUrl(),
33999
+ shape: shape()
33894
34000
  };
33895
34001
  if (mode === "new") a.podName = f().podName.value.trim().toLowerCase();
33896
- else a.pod = `https://${podHostOf()}/`;
34002
+ else a.pod = podUrl();
33897
34003
  return a;
33898
34004
  };
33899
34005
  const previewAddr = () => {
33900
34006
  const handle = f().handle.value.trim().toLowerCase();
33901
- const ph = podHostOf();
33902
- $("preview").textContent = handle && ph ? `@${handle}@${ph}` : "@\u2026@\u2026";
34007
+ const host = shape() === "front" ? location.host : f().mode.value === "existing" ? podUrl() ? new URL(podUrl()).host : "" : podHostOf();
34008
+ $("preview").textContent = handle && host ? `@${handle}@${host}` : "@\u2026@\u2026";
34009
+ };
34010
+ const applyShape = () => {
34011
+ const fixed = pathPod();
34012
+ for (const r of f().shape) {
34013
+ if (fixed) r.checked = r.value === "front";
34014
+ }
34015
+ $("shape-group").hidden = fixed;
34016
+ $("shape-hint").hidden = !fixed;
33903
34017
  };
33904
- for (const el of $("form").elements) el.addEventListener("input", previewAddr);
34018
+ const applyMode = () => {
34019
+ const existing = f().mode.value === "existing";
34020
+ $("pod-field").hidden = !existing;
34021
+ $("podname-field").hidden = existing;
34022
+ $("provider-other-field").hidden = f().provider.value !== "";
34023
+ };
34024
+ for (const el of $("form").elements) for (const evt of ["input", "change"]) el.addEventListener(evt, () => {
34025
+ applyMode();
34026
+ applyShape();
34027
+ previewAddr();
34028
+ });
34029
+ for (const evt of ["input", "change"]) {
34030
+ $("provider").addEventListener(evt, learnLayout);
34031
+ $("providerOther").addEventListener(evt, learnLayout);
34032
+ }
34033
+ applyMode();
34034
+ learnLayout();
33905
34035
  const STEP_IDS = ["step-1", "step-2"];
33906
34036
  const FOCUS = { 1: "provider", 2: "handle" };
33907
34037
  const goStep = (n) => {
@@ -33910,15 +34040,23 @@ ${e.detail}` : "");
33910
34040
  });
33911
34041
  $("err-1").textContent = "";
33912
34042
  $("form-error").textContent = "";
33913
- if (n === 2) previewAddr();
34043
+ if (n === 2) {
34044
+ applyShape();
34045
+ previewAddr();
34046
+ learnLayout();
34047
+ }
33914
34048
  if (FOCUS[n]) $(FOCUS[n]).focus();
33915
34049
  };
33916
34050
  const validateStep1 = () => {
33917
- if (!providerHost()) return "A valid pod provider URL is required.";
33918
- const sub = f().podName.value.trim().toLowerCase();
33919
- if (!sub) return "A pod username/subdomain is required.";
33920
- const sp = window.fedipodHandleProblem(sub);
33921
- if (sp) return `Pod username: ${sp}`;
34051
+ if (!providerHost()) return f().provider.value === "" ? "A pod provider address is required under Other\u2026." : "A valid pod provider URL is required.";
34052
+ if (f().mode.value === "existing") {
34053
+ if (!podUrl()) return "A pod address is required, like https://alice.solidcommunity.net/ or https://server.example/alice/.";
34054
+ } else {
34055
+ const sub = f().podName.value.trim().toLowerCase();
34056
+ if (!sub) return "A pod username/subdomain is required.";
34057
+ const sp = window.fedipodHandleProblem(sub);
34058
+ if (sp) return `Pod username: ${sp}`;
34059
+ }
33922
34060
  if (!f().email.value.trim()) return "A pod email is required.";
33923
34061
  if (!f().password.value) return "A pod password is required.";
33924
34062
  return null;