fedipod-server 0.14.1 → 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.
@@ -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
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
  }
@@ -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
+ * shared 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.15.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",
package/web/app/README.md CHANGED
@@ -73,5 +73,5 @@ See the `MastoApi` options in `agent.mjs`.
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
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. |
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 path of a shared 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. |
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 shared 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.
@@ -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 path on a shared 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 path of a shared 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;