fedipod-server 0.14.0 → 0.15.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.
@@ -13,6 +13,7 @@
13
13
  --heading: #123f74; --sub: #565656; --line: #b0b0b0; --accent: #12467e;
14
14
  --btn: #3a5f43; --btn-hover: #30503a; --btn-text: #ffffff;
15
15
  --field-bg: #ececec; --field-text: #141414; --field-border: #6f6f6f;
16
+ --select-bg: #dce7f3; --select-border: #12467e;
16
17
  --err: #b00020; --warn: #8a4b00; --focus: #12467e;
17
18
  }
18
19
  @media (prefers-color-scheme: dark) {
@@ -20,6 +21,7 @@
20
21
  --bg: #191919; --fg: #f3f3f3;
21
22
  --heading: #8fc2f2; --sub: #d2d2d2; --line: #6d6d6d; --accent: #8fc2f2;
22
23
  --field-bg: #474747; --field-text: #f5f5f5; --field-border: #9aa0a6;
24
+ --select-bg: #2f4a66; --select-border: #8fc2f2;
23
25
  --btn: #4a7a54; --btn-hover: #3f6a49;
24
26
  --err: #ff9b9b; --warn: #ffb366; --focus: #8fc2f2;
25
27
  }
@@ -42,6 +44,10 @@ input[type=text], input[type=email], input[type=url], input[type=password], sele
42
44
  font: inherit; font-size: 1rem; width: 100%; padding: .55rem; box-sizing: border-box;
43
45
  border: 1px solid var(--field-border); border-radius: .3rem;
44
46
  background: var(--field-bg); color: var(--field-text); }
47
+ /* A list to pick from looks different from a box to type in: its own tint,
48
+ the accent border, and the same on its options. */
49
+ select { background: var(--select-bg); border-color: var(--select-border); }
50
+ select option { background: var(--select-bg); color: var(--field-text); }
45
51
  ::placeholder { color: var(--sub); opacity: 1; }
46
52
  .choice { display: block; margin: .4rem 0; font-weight: normal; }
47
53
  .choice input { margin-right: .5rem; width: auto; }
@@ -88,7 +94,7 @@ a { color: var(--accent); }
88
94
  <section id="landing" hidden>
89
95
  <h2>Already have a FediPod account?</h2>
90
96
  <p class="row">
91
- <input type="text" id="signin-address" autocapitalize="off" spellcheck="false" placeholder="@me@mypod"
97
+ <input type="text" id="signin-address" autocomplete="username" autocapitalize="off" spellcheck="false" placeholder="@me@mypod"
92
98
  aria-label="Your FediPod address, in the form @you@yourpod">
93
99
  <button type="button" class="primary" id="signin">sign in</button>
94
100
  </p>
@@ -136,18 +142,42 @@ a { color: var(--accent); }
136
142
  </div>
137
143
 
138
144
  <label for="provider">Pod provider</label>
139
- <input type="url" id="provider" name="provider" autocomplete="url" placeholder="https://solidcommunity.net">
145
+ <!-- The providers listed at https://solidproject.org/get_a_pod that run Community
146
+ Solid Server, whose account API is what "A new pod" uses. Checked 2026-09-14. -->
147
+ <select id="provider" name="provider">
148
+ <option value="https://solidcommunity.net">solidcommunity.net</option>
149
+ <option value="https://privatedatapod.com">privatedatapod.com</option>
150
+ <option value="https://solid.redpencil.io">redpencil.io</option>
151
+ <option value="https://pods.solidcommunity.au">solidcommunity.au</option>
152
+ <option value="https://solidweb.me">solidweb.me</option>
153
+ <option value="https://teamid.live">teamid.live</option>
154
+ <option value="">Other…</option>
155
+ </select>
156
+ <div id="provider-other-field" hidden>
157
+ <label for="providerOther">Provider address</label>
158
+ <input type="url" id="providerOther" name="providerOther" autocomplete="url" placeholder="https://pods.example.org">
159
+ </div>
160
+
161
+ <div id="podname-field">
162
+ <label for="podName">Pod username</label>
163
+ <input type="text" id="podName" name="podName" autocomplete="off" aria-describedby="podname-hint">
164
+ <p class="hint" id="podname-hint">Depending on your provider's setup, your pod will be at
165
+ either https://USERNAME.podprovider or https://podprovider/USERNAME.</p>
166
+ </div>
140
167
 
141
- <label for="podName">Pod username/subdomain</label>
142
- <input type="text" id="podName" name="podName" autocomplete="off" aria-describedby="podname-hint">
143
- <p class="hint" id="podname-hint">The subdomain of your pod. On solidcommunity.net, alice
144
- becomes the pod https://alice.solidcommunity.net.</p>
168
+ <div id="pod-field" hidden>
169
+ <label for="pod">Pod address</label>
170
+ <input type="url" id="pod" name="pod" autocomplete="url" aria-describedby="pod-hint"
171
+ placeholder="https://alice.solidcommunity.net/ or https://server.example/alice/">
172
+ <p class="hint" id="pod-hint">The address of your pod: its own host, or a path on a
173
+ shared host.</p>
174
+ </div>
145
175
 
146
- <label for="email">Pod email</label>
176
+ <label for="email">Email (used as pod id)</label>
147
177
  <input type="email" id="email" name="email" autocomplete="email" placeholder="you@example.org">
148
178
 
149
179
  <label for="password">Pod password</label>
150
- <input type="password" id="password" name="password" autocomplete="current-password"
180
+ <input type="password" id="password" name="password" autocomplete="new-password"
151
181
  aria-describedby="password-hint">
152
182
  <p class="hint" id="password-hint">Your password is sent once, to make your account and to
153
183
  lock your signing key. It is never stored. The credential used to set things up is
@@ -172,6 +202,14 @@ a { color: var(--accent); }
172
202
  <p class="hint" id="handle-hint">The name in your Fediverse address. This is permanent.
173
203
  Letters, digits, and hyphens only.</p>
174
204
 
205
+ <div role="radiogroup" aria-labelledby="shape-label" id="shape-group">
206
+ <span id="shape-label" class="grouplabel">Where your address lives</span>
207
+ <label class="choice"><input type="radio" name="shape" value="pod" checked> On your pod</label>
208
+ <label class="choice"><input type="radio" name="shape" value="front"> At this site</label>
209
+ </div>
210
+ <p class="hint" id="shape-hint" hidden>Your pod provider uses suffixes, so your only option
211
+ is to use an account on a gateway like this one.</p>
212
+
175
213
  <span id="addr-label" class="grouplabel">Your Fediverse address</span>
176
214
  <p class="address" id="preview" role="status" aria-live="polite" aria-labelledby="addr-label">@…@…</p>
177
215
  </fieldset>
@@ -44,6 +44,10 @@ export async function cacheOpenedKeys(actorUrl, keysRecord) {
44
44
  // already relies on. It is what lets the worker boot itself after an idle kill
45
45
  // without a soul around to type anything.
46
46
  export const keyCacheKey = (actorUrl) => `signing-keys:${actorUrl}`;
47
+ // The cache is keyed by the POD actor, whatever the identity advertises: a
48
+ // fronted identity's advertised actor is the gateway's, and keying by that
49
+ // would hide the opened copy sign-up stored and ask for the password again.
50
+ export const podActorOf = (urls) => (urls.toPod ? urls.toPod(urls.actor) : urls.actor);
47
51
 
48
52
  // Read the signing key for this actor and import it for signing.
49
53
  //
@@ -54,16 +58,17 @@ export const keyCacheKey = (actorUrl) => `signing-keys:${actorUrl}`;
54
58
  // there is from before wrapping and is used as it stands, while an envelope
55
59
  // needs the password and so has to go back to the page.
56
60
  export async function loadKeysFromPod(remote, urls) {
57
- const cached = await kvGet(keyCacheKey(urls.actor)).catch(() => null);
61
+ const podActor = podActorOf(urls);
62
+ const cached = await kvGet(keyCacheKey(podActor)).catch(() => null);
58
63
  if (isOpenedKey(cached)) return fromCache(cached);
59
64
  // A copy stored as PEM by an earlier build: import it and store the key form
60
65
  // in its place, so the PEM is gone from storage after one boot.
61
- if (cached?.rsa?.privatePem) return cacheOpenedKeys(urls.actor, cached);
66
+ if (cached?.rsa?.privatePem) return cacheOpenedKeys(podActor, cached);
62
67
 
63
68
  const doc = await podState.readWrappedKeys(remote, urls);
64
69
  if (isKeyEnvelope(doc)) throw new KeyPasswordNeeded();
65
70
  if (!doc || !doc.rsa) throw new Error('no signing key on the pod — sign up did not finish');
66
71
  // A pre-wrapping install. Cache it so the next boot is one read, and leave
67
72
  // the pod's copy alone: re-wrapping it would need the password we do not have.
68
- return cacheOpenedKeys(urls.actor, doc);
73
+ return cacheOpenedKeys(podActor, doc);
69
74
  }
@@ -48,9 +48,24 @@ const progressKey = (a) => [a.issuer, a.mode, a.handle, a.mode === 'new' ? (a.po
48
48
  * draw the same tick list the CLI setup shows. Resumable: see PROGRESS above.
49
49
  *
50
50
  * answers: { mode:'new'|'existing', issuer, email, password, handle,
51
- * podName?, pod?, gateway? }
51
+ * podName?, pod?, gateway?, shape?: 'pod'|'front' }
52
+ *
53
+ * `shape` is where the address lives. On the pod, `@handle@yourpod`, with the
54
+ * gateway as a mail door only — the default. At the gateway, `@handle@front`,
55
+ * a fronted identity whose documents still live on the pod. A pod on a path of
56
+ * a shared host cannot answer WebFinger, so it is fronted whatever was asked.
57
+ *
52
58
  * returns: { credential, config, actorUrl, address, keysPublic }
53
59
  */
60
+ /** A fronted name is one per gateway; a taken one is refused before anything is made. */
61
+ async function assertFrontNameFree(frontOrigin, handle) {
62
+ const res = await fetch(`${frontOrigin.replace(/\/$/, '')}/api/handle?handle=${encodeURIComponent(handle)}`,
63
+ { headers: { accept: 'application/json' } }).catch(() => null);
64
+ const d = res ? await res.json().catch(() => ({})) : null;
65
+ if (!d) throw new Error(`${new URL(frontOrigin).host} did not answer whether @${handle} is free`);
66
+ if (!d.available) throw new Error(d.reason || `the name @${handle}@${new URL(frontOrigin).host} is taken — choose another handle`);
67
+ }
68
+
54
69
  export async function signUp(answers, { onStep = () => {}, frontOrigin = null } = {}) {
55
70
  const { mode, issuer, email, password, handle } = answers;
56
71
  const bad = handleProblem(handle);
@@ -58,6 +73,10 @@ export async function signUp(answers, { onStep = () => {}, frontOrigin = null }
58
73
  if (!email) throw new Error('an email is required'); // recovery + account login
59
74
  if (!password) throw new Error('a password is required');
60
75
  if (mode === 'existing' && !answers.pod) throw new Error('a pod address is required');
76
+ const wantsFront = answers.shape === 'front';
77
+ if (wantsFront && !frontOrigin) throw new Error('an address at the gateway needs a gateway, and this page has none');
78
+ // A name at the gateway is one per gateway: settle it before making anything.
79
+ if (wantsFront) await assertFrontNameFree(frontOrigin, handle);
61
80
 
62
81
  const key = progressKey(answers);
63
82
  const prog = PROGRESS.get(key) || {};
@@ -91,17 +110,18 @@ export async function signUp(answers, { onStep = () => {}, frontOrigin = null }
91
110
  }
92
111
  const pod = prog.pod; const webId = prog.webId || null;
93
112
 
94
- // WebFinger is answered at a host root, so the address only works if the pod
95
- // is the root of its own host. Refuse a path pod rather than making an
96
- // account nobody can find.
97
- const podUrl = new URL(pod);
98
- if (podUrl.pathname !== '/') {
99
- throw new Error(`${pod} is a path on ${podUrl.host}, not its own host. `
100
- + 'A Fediverse address lives at a host root, so this pod cannot carry one. '
101
- + 'Use a pod that is the root of its own subdomain.');
113
+ // WebFinger is answered only at a host root. A pod on a path of a shared
114
+ // host therefore takes its address at the gateway, whatever was asked; a
115
+ // pod at its own root keeps the choice made on the form.
116
+ const pathPod = new URL(pod).pathname !== '/';
117
+ const fronted = pathPod || wantsFront;
118
+ if (fronted && !frontOrigin) {
119
+ throw new Error(`${pod} is a path on a shared host, so its address must live at a gateway, and this page has none.`);
102
120
  }
121
+ if (pathPod && !wantsFront) await assertFrontNameFree(frontOrigin, handle);
103
122
 
104
- const actorUrl = actorUrlFor(pod);
123
+ const actorUrl = actorUrlFor(pod); // where the documents live, always
124
+ const frontActor = fronted ? `${frontOrigin.replace(/\/$/, '')}/u/${handle}/ap/actor` : null;
105
125
 
106
126
  // --- credential --- (a fresh attempt re-mints; a resumed one reuses it)
107
127
  const cred = step('credential');
@@ -143,12 +163,48 @@ export async function signUp(answers, { onStep = () => {}, frontOrigin = null }
143
163
  // The opened copy is kept in this browser (IndexedDB) so the worker can boot
144
164
  // itself after an idle kill with nobody there to type a password. A browser
145
165
  // that has no copy asks for the password once — see boot.mjs.
166
+ // Connect the gateway (fedipod.net) before the key is made, because a
167
+ // fronted identity's key is stamped with the gateway actor. Inbox-only: the
168
+ // actor advertises the door as its inbox and keeps its own ids. Fronted: the
169
+ // gateway answers WebFinger for @handle@front and serves the actor at its own
170
+ // address, rewriting reads onto the pod. Either way the door verifies each
171
+ // delivery and forwards the clean mail to the pod; the agent trusts the
172
+ // door's receipt. The same DPoP session proves the pod to the gateway — no
173
+ // password reaches it.
174
+ let gateway = answers.gateway || prog.gateway || null;
175
+ if (frontOrigin && !gateway) {
176
+ const gw = step('gateway');
177
+ gw.running(fronted ? `taking your address at ${new URL(frontOrigin).host}` : `connecting your mail door on ${new URL(frontOrigin).host}`);
178
+ const res = await session.fetch(`${frontOrigin.replace(/\/$/, '')}/api/attach`, {
179
+ method: 'POST', headers: { 'content-type': 'application/json' },
180
+ // podHome is the AP CONTAINER, not the pod root: the front builds the
181
+ // delivery target as `podHome + 'ap/inbox/'` (lib/front-core.mjs), so a
182
+ // bare pod root sends this identity's mail to <pod>/ap/inbox/ — outside
183
+ // the container the agent drains, where nothing would ever read it. The
184
+ // manage surface has always sent `urls.home`; this is the same value.
185
+ body: JSON.stringify({ handle, podHome: `${pod}${AP_ROOT}`, actorUrl, kind: 'person', fronted }),
186
+ });
187
+ const d = await res.json().catch(() => ({}));
188
+ if (res.status !== 201 || !d.hmacSecret) {
189
+ throw new Error(`could not connect the gateway (HTTP ${res.status}): ${d.error || ''}`);
190
+ }
191
+ gateway = fronted
192
+ ? { url: `${frontOrigin.replace(/\/$/, '')}/u/${handle}/ap/inbox/`, frontActor: String(d.frontActor || frontActor), hmacSecret: d.hmacSecret, mode: 'trust' }
193
+ : { url: d.doorInbox, hmacSecret: d.hmacSecret, mode: 'trust' };
194
+ prog.gateway = gateway;
195
+ gw.ok();
196
+ } else if (frontOrigin && gateway) {
197
+ step('gateway').ok(); // resumed
198
+ }
199
+
146
200
  const keysStep = step('keys');
147
201
  let keys;
148
202
  if (!prog.keysStored) {
149
203
  keysStep.running('making your signing key and locking it under your password');
150
204
  keys = await generateKeys();
151
- keys.mintedFor = actorUrl; // one key, one actor (lib/keys.mjs)
205
+ // One key, one actor (lib/keys.mjs): the actor the world knows, which for
206
+ // a fronted identity is the gateway's address for it.
207
+ keys.mintedFor = gateway?.frontActor || actorUrl;
152
208
  const remote = new BrowserRemotePod(session, { webId: credential.webId, role: 'signup', log: () => {} });
153
209
  // Owner-only, and THEN the key — one operation, so the order cannot be got
154
210
  // wrong here or anywhere else. A pod that refuses the ACL write is not a
@@ -174,34 +230,6 @@ export async function signUp(answers, { onStep = () => {}, frontOrigin = null }
174
230
  keysStep.ok();
175
231
  }
176
232
 
177
- // Connect the mail door: attach to the gateway (fedipod.net), inbox-only, so
178
- // the actor advertises the door as its inbox. The door verifies each delivery
179
- // and forwards the clean mail to the pod; the agent trusts the door's receipt.
180
- // The same DPoP session proves the pod to the gateway — no password reaches it.
181
- let gateway = answers.gateway || prog.gateway || null;
182
- if (frontOrigin && !gateway) {
183
- const gw = step('gateway');
184
- gw.running(`connecting your mail door on ${new URL(frontOrigin).host}`);
185
- const res = await session.fetch(`${frontOrigin.replace(/\/$/, '')}/api/attach`, {
186
- method: 'POST', headers: { 'content-type': 'application/json' },
187
- // podHome is the AP CONTAINER, not the pod root: the front builds the
188
- // delivery target as `podHome + 'ap/inbox/'` (lib/front-core.mjs), so a
189
- // bare pod root sends this identity's mail to <pod>/ap/inbox/ — outside
190
- // the container the agent drains, where nothing would ever read it. The
191
- // manage surface has always sent `urls.home`; this is the same value.
192
- body: JSON.stringify({ handle, podHome: `${pod}${AP_ROOT}`, actorUrl, kind: 'person' }),
193
- });
194
- const d = await res.json().catch(() => ({}));
195
- if (res.status !== 201 || !d.hmacSecret) {
196
- throw new Error(`could not connect the mail door (HTTP ${res.status}): ${d.error || ''}`);
197
- }
198
- gateway = { url: d.doorInbox, hmacSecret: d.hmacSecret, mode: 'trust' };
199
- prog.gateway = gateway;
200
- gw.ok();
201
- } else if (frontOrigin && gateway) {
202
- step('gateway').ok(); // resumed
203
- }
204
-
205
233
  const config = {
206
234
  remotePod: pod, root: AP_ROOT, handle, name: handle, issuer: credential.issuerOrigin,
207
235
  ...(gateway ? { gateway } : {}),
@@ -233,7 +261,7 @@ export async function signUp(answers, { onStep = () => {}, frontOrigin = null }
233
261
 
234
262
  PROGRESS.delete(key); // finished — nothing left to resume
235
263
 
236
- const host = new URL(pod).host;
264
+ const host = gateway?.frontActor ? new URL(gateway.frontActor).host : new URL(pod).host;
237
265
  return {
238
266
  credential, config, actorUrl,
239
267
  address: `@${handle}@${host}`,
@@ -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 path on a shared 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;