From 02ceb7d205acfdc0f974b132e2439d02b7ba63e5 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 14 May 2026 08:52:08 +0200 Subject: [PATCH 1/7] feat: mode-agnostic landing + HEAD-adaptive Sign up/Sign in (#435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 refinement of #427. Builds on #433/#434. The original Phase 3 page was mode-specific: it inlined "Create a pod" and "Sign in" buttons based on the seed-time singleUser/idp flags. Two problems showed up in practice: 1. The seeded /index.html went stale on mode change. Once seeded by a --single-user run, every later run (any mode) served the same HTML because of skip-if-exists, so a multi-user restart still showed "Mode: single-user" and a non-functional Sign-in button. 2. "Sign in" linked to /idp, which 404s when IDP isn't enabled. The button always rendered (we worked it out at write time), so a mode change to no-IDP left the page promising something the server no longer offers. Refinement: render once, adapt at load. - The page is now mode-agnostic: same headline, same explainer, same primary "Get started" CTA pointing at jss.live/docs/getting-started/. - Sign up + Sign in anchors are emitted but `hidden` by default. An inline script does HEAD /idp/register on load: 200 → reveal both Sign up and Sign in (multi-user + IDP, open) 403 → reveal Sign in only (single-user — registration disabled) 404 / network error → leave both hidden (no IDP) Same seeded HTML keeps working when the operator switches modes. - The live server URL is filled in client-side from window.location.origin, so the page shows whichever host the visitor actually used (works behind reverse proxies, across localhost / 127.0.0.1 / LAN-IP, without re-rendering the file). - Tone tightened: no emoji headline, sentence case, brief paragraph about Solid for visitors landing on a public pod for the first time. Polished enough to leave as a public landing on an IDP / multi-user deployment without it looking like a first-run celebration screen. - Footer carries the GitHub link and a single-line customise hint. The seed model itself is unchanged: still skip-if-exists for /index.html, /.acl, and /index.html.acl. Operator-written files are preserved. The .acl seeds (relative './' / './index.html' from #434) keep an operator-uploaded /index.html publicly readable without the operator having to write WAC by hand. Tests: server-root.test.js rewritten for the new model. - 14 tests: same set of capabilities, refocused on the mode-agnostic behaviour (one render, all modes), the Get started link, hidden Sign-up/Sign-in anchors with the right data-cond attributes, the HEAD probe + live-URL scripts, version interpolation + escape, and the footer hints. - The previous mode-specific render assertions (Create a pod text, Personal-pod-for-name subtitle, $-pattern and {{token}} re-scan regressions) drop with the values they were guarding — none of those interpolated values exist in the new template. 813/813 tests pass. Refs #435. --- src/ui/server-root.html | 85 ++++++++++++++++++++------ src/ui/server-root.js | 59 +++++------------- test/server-root.test.js | 128 +++++++++++++++++++-------------------- 3 files changed, 142 insertions(+), 130 deletions(-) diff --git a/src/ui/server-root.html b/src/ui/server-root.html index cbcc35e..e86db81 100644 --- a/src/ui/server-root.html +++ b/src/ui/server-root.html @@ -4,57 +4,64 @@ {{title}} - +
-

{{heading}}

-
{{subtitle}}
-

{{description}}

+

Welcome

+

Your JSS Solid pod is running at this server.

+

This is a Solid pod server. Solid is an open standard for personal data — your data lives in pods you own, and apps connect to it.

- {{actions}} +
Version{{version}}
@@ -63,8 +70,46 @@

{{heading}}

- Powered by JSS + Powered by JSS · GitHub + Customise this page: edit /index.html in your data dir
+ + diff --git a/src/ui/server-root.js b/src/ui/server-root.js index d406163..eaea77d 100644 --- a/src/ui/server-root.js +++ b/src/ui/server-root.js @@ -36,34 +36,23 @@ function listFeatures(options = {}) { return f; } -/** - * Build an HTML snippet of action buttons based on server mode. - */ -function renderActions({ singleUser, idp }) { - const buttons = []; - if (!singleUser && idp) { - buttons.push('Create a pod'); - buttons.push('Sign in'); - } else if (singleUser && idp) { - buttons.push('Sign in'); - } - buttons.push('Docs'); - return `
${buttons.join('\n ')}
`; -} - /** * Render the landing page as an HTML string. * + * The page itself is mode-agnostic — it doesn't change based on + * single-user vs multi-user, and Sign up / Sign in are revealed at + * load time by an inline HEAD probe against /idp/register. So the + * same seeded HTML keeps working when the operator changes modes + * without regenerating the file. See #435. + * * @param {object} ctx - * @param {string} ctx.version - JSS version - * @param {boolean} [ctx.singleUser] - * @param {boolean} [ctx.idp] - * @param {string} [ctx.singleUserName] - * @param {object} [ctx.enabled] - Map of feature flags + * @param {string} [ctx.version] - JSS version (rendered into the info box) + * @param {boolean} [ctx.singleUser] - Drives the "Mode" label only + * @param {object} [ctx.enabled] - Map of feature flags for the pills row * @returns {string} HTML */ export function renderServerRoot(ctx = {}) { - const { version = 'unknown', singleUser = false, idp = false, singleUserName, enabled = {} } = ctx; + const { version = 'unknown', singleUser = false, enabled = {} } = ctx; const tpl = readFileSync(TEMPLATE_PATH, 'utf8'); const mode = singleUser ? 'single-user' : 'multi-user'; @@ -71,29 +60,13 @@ export function renderServerRoot(ctx = {}) { .map(f => `${f}`) .join(' '); - const heading = 'JSS'; - const subtitle = singleUser - ? `Personal pod${singleUserName && singleUserName !== '/' ? ` for ${escape(singleUserName)}` : ''}` - : 'A personal data server'; - const description = singleUser - ? 'This server hosts a personal data pod. Apps come to the data rather than the other way around.' - : 'This server hosts personal data pods on the web. Each pod is a space you own, with your own identity and access control.'; - - // Single-pass token substitution. Sequential .replace() calls would - // re-scan already-substituted values, so a `singleUserName` of e.g. - // `{{actions}}` would land inside `subtitle`, then get expanded by - // the later `.replace(/{{actions}}/g, …)` — letting a pod owner - // inject other template fragments via their name. With a single - // pass over the original template, each {{token}} is matched once - // and replaced with its value; `$` inside any value is also harmless - // because the function form of replace skips substitution patterns. - // See #433 review thread. + // Single-pass token substitution. Each {{token}} in the original + // template is matched once and replaced from `values`; substituted + // text is not re-scanned, so a `$` or stray `{{…}}` in a value + // can't cause re-substitution or hit String.prototype.replace's + // `$&` substitution patterns. See #433 review thread. const values = { - title: heading, - heading, - subtitle, - description, - actions: renderActions({ singleUser, idp }), + title: 'JSS Solid pod', version: escape(version), mode, features diff --git a/test/server-root.test.js b/test/server-root.test.js index 1ae701c..432dde1 100644 --- a/test/server-root.test.js +++ b/test/server-root.test.js @@ -1,5 +1,11 @@ /** - * Server-root landing page seed (#276). + * Server-root landing page seed (#276 / #433 / #435). + * + * Phase 3 (#433) seeded a mode-specific landing page that went stale on + * mode change. Phase 3 refinement (#435) replaced the mode-specific copy + * with a single mode-agnostic page that adapts at load time via a HEAD + * probe against /idp/register, so the same seeded HTML keeps working + * across modes without regenerating the file. */ import { describe, it, before, after } from 'node:test'; @@ -22,8 +28,8 @@ describe('Server-root landing page', () => { const res = await request('/', { headers: { Accept: 'text/html' } }); assertStatus(res, 200); const body = await res.text(); - assert.match(body, /JSS<\/title>/); - assert.match(body, /A personal data server/); + assert.match(body, /<title>JSS Solid pod<\/title>/); + assert.match(body, /Your JSS Solid pod is running/); }); it('landing page is publicly readable (no auth required)', async () => { @@ -97,38 +103,61 @@ describe('Server-root landing — operator override', () => { }); }); -describe('renderServerRoot — mode-specific output', () => { - it('multi-user + IDP shows Create a pod + Sign in', () => { - const html = renderServerRoot({ version: '1.0.0', singleUser: false, idp: true }); - assert.match(html, /Create a pod/); - assert.match(html, /href="\/idp\/register"/); - assert.match(html, /href="\/idp"/); +describe('renderServerRoot', () => { + // Mode-agnostic copy: the same page is served regardless of single-user + // vs multi-user. The status pill carries the mode label; the buttons + // adapt at load time via the HEAD probe (verified separately below). + it('renders the same mode-agnostic copy regardless of singleUser flag', () => { + const single = renderServerRoot({ version: '1.0.0', singleUser: true }); + const multi = renderServerRoot({ version: '1.0.0', singleUser: false }); + + // Same welcome copy, same primary CTA, same explainer. + for (const html of [single, multi]) { + assert.match(html, /<h1>Welcome<\/h1>/); + assert.match(html, /Your JSS Solid pod is running/); + assert.match(html, /open standard for personal data/); + } + + // Mode pill differs. + assert.match(single, /<code>single-user<\/code>/); + assert.match(multi, /<code>multi-user<\/code>/); + }); + + it('always emits the Get started button pointing at the docs', () => { + const html = renderServerRoot({ version: '1.0.0' }); + assert.match(html, /href="https:\/\/jss\.live\/docs\/getting-started\/"/); + assert.match(html, /Get started/); + }); + + it('emits Sign up + Sign in buttons hidden for the HEAD probe to reveal', () => { + const html = renderServerRoot({ version: '1.0.0' }); + // Both anchors are present in every mode; the inline script reveals + // them based on what /idp/register actually returns. + assert.match(html, /<a href="\/idp\/register"[^>]*data-cond="register"[^>]*hidden/); + assert.match(html, /<a href="\/idp"[^>]*data-cond="login"[^>]*hidden/); + assert.match(html, /Sign up/); assert.match(html, /Sign in/); }); - it('single-user + IDP shows Sign in only (no Create a pod)', () => { - const html = renderServerRoot({ version: '1.0.0', singleUser: true, idp: true, singleUserName: 'alice' }); - assert.doesNotMatch(html, /Create a pod/); - assert.match(html, /Sign in/); - }); - - it('multi-user without IDP shows only the Docs link', () => { - const html = renderServerRoot({ version: '1.0.0', singleUser: false, idp: false }); - assert.doesNotMatch(html, /Create a pod/); - assert.doesNotMatch(html, /Sign in/); - assert.match(html, /Docs/); + it('includes the HEAD-adaptive script targeting /idp/register', () => { + const html = renderServerRoot({ version: '1.0.0' }); + assert.match(html, /fetch\(['"]\/idp\/register['"]/); + assert.match(html, /method:\s*['"]HEAD['"]/); + // The three documented branches: 200 → both, 403 → login only, + // anything else → neither. Assert the magic numbers are present. + assert.match(html, /res\.status === 200/); + assert.match(html, /res\.status === 403/); }); - it('single-user subtitle includes the pod name when provided', () => { - const html = renderServerRoot({ version: '1.0.0', singleUser: true, idp: false, singleUserName: 'alice' }); - assert.match(html, /Personal pod for alice/); + it('includes the live-URL script that fills in window.location.origin', () => { + const html = renderServerRoot({ version: '1.0.0' }); + assert.match(html, /id="server-url"/); + assert.match(html, /window\.location\.origin/); }); - it('lists enabled features', () => { + it('lists enabled features as pills', () => { const html = renderServerRoot({ version: '1.0.0', - singleUser: false, - idp: true, enabled: { idp: true, nostr: true, webrtc: true, terminal: true } }); assert.match(html, /<span>idp<\/span>/); @@ -137,7 +166,7 @@ describe('renderServerRoot — mode-specific output', () => { assert.match(html, /<span>terminal<\/span>/); }); - it('interpolates version', () => { + it('interpolates version into the info box', () => { const html = renderServerRoot({ version: '9.9.9' }); assert.match(html, /<code>9\.9\.9<\/code>/); }); @@ -148,45 +177,10 @@ describe('renderServerRoot — mode-specific output', () => { assert.match(html, /<script>/); }); - // Regression for token re-scanning (#433 review thread): if the - // renderer ran a chain of sequential .replace() calls, a value - // containing a literal `{{actions}}` would land inside the subtitle - // and then get expanded by the later `.replace(/{{actions}}/g, ...)`, - // letting any pod owner inject other template fragments via their - // singleUserName. The single-pass substitution prevents that. - it('does not re-scan substituted values for further template tokens', () => { - const html = renderServerRoot({ - version: '1.0.0', - singleUser: true, - idp: false, - // The HTML escape only touches & < > " — { } pass through, so the - // token would land in the output verbatim if the substitution were - // multi-pass. - singleUserName: 'evil{{actions}}name' - }); - assert.match(html, /Personal pod for evil\{\{actions\}\}name/, - 'singleUserName containing a template token should appear as plain text, not be re-templated'); - // Sanity: the real {{actions}} slot is still resolved (Docs link is always present). - assert.match(html, /href="https:\/\/javascriptsolidserver\.github\.io\/docs/); - }); - - // Regression for the `$&` substitution gotcha (#433): a string used as - // the second argument of String.prototype.replace interprets `$&`, - // `$1`, etc. as substitution patterns. Interpolated values can contain - // `$` (notably a singleUserName), so the renderer uses the function - // form of replace instead. Asserting the literal `$&` survives the - // round-trip would mean it survived as plain text. - it('preserves $-patterns in singleUserName instead of treating them as replacement specials', () => { - const html = renderServerRoot({ - version: '1.0.0', - singleUser: true, - idp: false, - singleUserName: 'foo$&bar' - }); - // The HTML escape converts `&` to `&`; the rest must stay verbatim, - // not be replaced by the matched template token. - assert.match(html, /Personal pod for foo\$&bar/, - 'singleUserName containing "$&" should land as-is, not trigger String.replace substitution'); - assert.doesNotMatch(html, /\{\{subtitle\}\}/, 'subtitle token should be fully consumed'); + it('points the footer at the GitHub repo and the customise hint', () => { + const html = renderServerRoot({ version: '1.0.0' }); + assert.match(html, /href="https:\/\/github\.com\/JavaScriptSolidServer\/JavaScriptSolidServer"/); + assert.match(html, /Customise this page/); + assert.match(html, /<code>\/index\.html<\/code>/); }); }); From 692932faffafb2dd10890f66023e9669daed523c Mon Sep 17 00:00:00 2001 From: Melvin Carvalho <melvincarvalho@gmail.com> Date: Thu, 14 May 2026 08:56:15 +0200 Subject: [PATCH 2/7] fix: point Get started CTA at /docs/getting-started/introduction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docusaurus 3 doesn't auto-generate a category index page, so /docs/getting-started/ 404s on jss.live. The canonical URL for the intro page is /docs/getting-started/introduction — point the landing's primary CTA there. --- src/ui/server-root.html | 2 +- test/server-root.test.js | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ui/server-root.html b/src/ui/server-root.html index e86db81..5454778 100644 --- a/src/ui/server-root.html +++ b/src/ui/server-root.html @@ -58,7 +58,7 @@ <h1>Welcome</h1> <p class="explainer">This is a Solid pod server. Solid is an open standard for personal data — your data lives in pods you own, and apps connect to it.</p> <div class="actions"> - <a href="https://jss.live/docs/getting-started/" class="btn btn-primary">Get started →</a> + <a href="https://jss.live/docs/getting-started/introduction" class="btn btn-primary">Get started →</a> <a href="/idp/register" class="btn btn-secondary" data-cond="register" hidden>Sign up</a> <a href="/idp" class="btn btn-secondary" data-cond="login" hidden>Sign in</a> </div> diff --git a/test/server-root.test.js b/test/server-root.test.js index 432dde1..5f946b8 100644 --- a/test/server-root.test.js +++ b/test/server-root.test.js @@ -123,9 +123,12 @@ describe('renderServerRoot', () => { assert.match(multi, /<code>multi-user<\/code>/); }); - it('always emits the Get started button pointing at the docs', () => { + it('always emits the Get started button pointing at the docs introduction', () => { const html = renderServerRoot({ version: '1.0.0' }); - assert.match(html, /href="https:\/\/jss\.live\/docs\/getting-started\/"/); + // The canonical URL is the introduction page, not the category. + // Docusaurus 3 doesn't auto-generate a category index page, so + // /docs/getting-started/ would 404. Link to the real document. + assert.match(html, /href="https:\/\/jss\.live\/docs\/getting-started\/introduction"/); assert.match(html, /Get started/); }); From 694a5dfe9622a7ccddfb127c6732fc3fabf3ddba Mon Sep 17 00:00:00 2001 From: Melvin Carvalho <melvincarvalho@gmail.com> Date: Thu, 14 May 2026 09:08:32 +0200 Subject: [PATCH 3/7] review: address Copilot pickups on #436 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three concrete fixes from the review thread. 1. .btn { display: inline-block } overrode the UA stylesheet's [hidden] { display: none } (equal specificity, author rule wins because it's later in the cascade). Sign up / Sign in would have flashed visible before the HEAD probe finished. Add an explicit [hidden] { display: none !important } so the attribute stays authoritative; new test pins the rule down. 2. The mode pill and feature-pills row were rendered into the seeded HTML at first start. Skip-if-exists then froze them — a mode change later would have left the seeded copy showing the old mode and feature flags even though the rest of the page is meant to adapt without regeneration. Drop both from the seeded template; the CLI banner already lists them at startup. The renderer drops the listFeatures helper and the singleUser/enabled context inputs. 3. The HEAD-decision matrix was only verified by regex checks on the inline script's text — a wrong-button regression for 200/403/404 would have slipped through. Extract the matrix into a pure helper, `decideRevealForRegisterStatus(status)`, and unit-test the five cases. The inline script implements the same matrix literally; the regex test still pins the literals so they don't drift silently. 819/819 tests pass. --- src/ui/server-root.html | 15 ++++-- src/ui/server-root.js | 79 ++++++++++++++--------------- test/server-root.test.js | 107 +++++++++++++++++++++++++++------------ 3 files changed, 124 insertions(+), 77 deletions(-) diff --git a/src/ui/server-root.html b/src/ui/server-root.html index 5454778..b154282 100644 --- a/src/ui/server-root.html +++ b/src/ui/server-root.html @@ -35,6 +35,12 @@ cursor: pointer; transition: background 0.1s, border-color 0.1s; } + /* The .btn { display: inline-block } above otherwise overrides the + browser's UA `[hidden] { display: none }` rule (equal specificity, + author rule wins because it's later in the cascade). Without this, + the Sign up / Sign in buttons would flash visible before the HEAD + probe runs. !important keeps the [hidden] attribute authoritative. */ + [hidden] { display: none !important; } .btn-primary { background: #2c2c2c; color: #fff; } .btn-primary:hover { background: #000; } .btn-secondary { background: #fff; color: #2c2c2c; border-color: #ccc; } @@ -43,8 +49,6 @@ .info .row { display: flex; justify-content: space-between; padding: 0.2rem 0; } .info .label { color: #999; } .info code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; font-size: 0.9em; color: #555; } - .features { font-size: 0.85rem; color: #666; margin-top: 0.5rem; } - .features span { display: inline-block; background: #eee; padding: 0.15rem 0.5rem; border-radius: 3px; margin-right: 0.3rem; margin-bottom: 0.3rem; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; font-size: 0.8rem; } footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd; color: #999; font-size: 0.8rem; text-align: center; } footer a { color: #888; } footer small { display: block; margin-top: 0.5rem; } @@ -65,9 +69,12 @@ <h1>Welcome</h1> <div class="info"> <div class="row"><span class="label">Version</span><code>{{version}}</code></div> - <div class="row"><span class="label">Mode</span><code>{{mode}}</code></div> - <div class="features">{{features}}</div> </div> + <!-- Mode and enabled-feature pills used to live here, but the seeded + HTML can't reflect mode changes after first start (skip-if-exists). + The CLI banner already lists both at startup; surface them via a + runtime probe in a follow-up if needed. See #436 review. --> + <footer> Powered by <a href="https://jss.live">JSS</a> · <a href="https://github.com/JavaScriptSolidServer/JavaScriptSolidServer">GitHub</a> diff --git a/src/ui/server-root.js b/src/ui/server-root.js index eaea77d..8e4cb82 100644 --- a/src/ui/server-root.js +++ b/src/ui/server-root.js @@ -17,65 +17,62 @@ import { generatePublicReadAcl, serializeAcl } from '../wac/parser.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const TEMPLATE_PATH = join(__dirname, 'server-root.html'); -/** - * Collect the list of enabled features for display on the landing page. - */ -function listFeatures(options = {}) { - const f = []; - if (options.idp) f.push('idp'); - if (options.nostr) f.push('nostr'); - if (options.webrtc) f.push('webrtc'); - if (options.activitypub) f.push('activitypub'); - if (options.git) f.push('git'); - if (options.pay) f.push('payments'); - if (options.notifications) f.push('notifications'); - if (options.mashlib) f.push('mashlib'); - if (options.mongo) f.push('mongo'); - if (options.tunnel) f.push('tunnel'); - if (options.terminal) f.push('terminal'); - return f; -} - /** * Render the landing page as an HTML string. * - * The page itself is mode-agnostic — it doesn't change based on - * single-user vs multi-user, and Sign up / Sign in are revealed at - * load time by an inline HEAD probe against /idp/register. So the - * same seeded HTML keeps working when the operator changes modes - * without regenerating the file. See #435. + * The page is mode-agnostic — same HTML for single-user and multi-user. + * Sign up / Sign in are revealed at load time by an inline HEAD probe + * against /idp/register, so the seeded file keeps working across mode + * changes without regeneration. See #435. + * + * Only `version` is rendered into the seeded HTML — anything else that + * varies with server state (mode, enabled features) would go stale on + * the next mode change because of skip-if-exists. * * @param {object} ctx - * @param {string} [ctx.version] - JSS version (rendered into the info box) - * @param {boolean} [ctx.singleUser] - Drives the "Mode" label only - * @param {object} [ctx.enabled] - Map of feature flags for the pills row + * @param {string} [ctx.version] - JSS version (shown in the info box) * @returns {string} HTML */ export function renderServerRoot(ctx = {}) { - const { version = 'unknown', singleUser = false, enabled = {} } = ctx; - + const { version = 'unknown' } = ctx; const tpl = readFileSync(TEMPLATE_PATH, 'utf8'); - const mode = singleUser ? 'single-user' : 'multi-user'; - const features = listFeatures(enabled) - .map(f => `<span>${f}</span>`) - .join(' '); - // Single-pass token substitution. Each {{token}} in the original - // template is matched once and replaced from `values`; substituted - // text is not re-scanned, so a `$` or stray `{{…}}` in a value - // can't cause re-substitution or hit String.prototype.replace's - // `$&` substitution patterns. See #433 review thread. + // Single-pass token substitution. Each {{token}} is matched once + // against the original template and replaced from `values`; + // substituted text isn't re-scanned (a `$` or stray `{{…}}` in a + // value can't cause re-substitution or hit String.prototype.replace's + // `$&` substitution patterns). See #433 review thread. const values = { title: 'JSS Solid pod', - version: escape(version), - mode, - features + version: escape(version) }; return tpl.replace(/{{(\w+)}}/g, (match, key) => Object.prototype.hasOwnProperty.call(values, key) ? values[key] : match ); } +/** + * Decide which conditional buttons (Sign up, Sign in) to reveal based + * on the response status of `HEAD /idp/register`. Pure function so the + * 200 / 403 / 404 matrix can be unit-tested without DOM. The inline + * script in server-root.html implements the same matrix literally; + * keep them in sync. + * + * 200 → registration open: reveal both Sign up and Sign in + * 403 → IDP enabled but registration disabled (single-user mode): + * reveal Sign in only + * anything else (404, network error) → reveal neither (no IDP) + * + * @param {number|undefined} status - HTTP status code, or undefined for + * network error. + * @returns {{ register: boolean, login: boolean }} + */ +export function decideRevealForRegisterStatus(status) { + if (status === 200) return { register: true, login: true }; + if (status === 403) return { register: false, login: true }; + return { register: false, login: false }; +} + function escape(s = '') { return String(s) .replace(/&/g, '&') diff --git a/test/server-root.test.js b/test/server-root.test.js index 5f946b8..aa8a18b 100644 --- a/test/server-root.test.js +++ b/test/server-root.test.js @@ -12,7 +12,7 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert'; import fs from 'fs-extra'; import { createServer } from '../src/server.js'; -import { renderServerRoot } from '../src/ui/server-root.js'; +import { renderServerRoot, decideRevealForRegisterStatus } from '../src/ui/server-root.js'; import { startTestServer, stopTestServer, request, assertStatus } from './helpers.js'; describe('Server-root landing page', () => { @@ -105,22 +105,29 @@ describe('Server-root landing — operator override', () => { describe('renderServerRoot', () => { // Mode-agnostic copy: the same page is served regardless of single-user - // vs multi-user. The status pill carries the mode label; the buttons - // adapt at load time via the HEAD probe (verified separately below). - it('renders the same mode-agnostic copy regardless of singleUser flag', () => { - const single = renderServerRoot({ version: '1.0.0', singleUser: true }); - const multi = renderServerRoot({ version: '1.0.0', singleUser: false }); - - // Same welcome copy, same primary CTA, same explainer. - for (const html of [single, multi]) { - assert.match(html, /<h1>Welcome<\/h1>/); - assert.match(html, /Your JSS Solid pod is running/); - assert.match(html, /open standard for personal data/); - } - - // Mode pill differs. - assert.match(single, /<code>single-user<\/code>/); - assert.match(multi, /<code>multi-user<\/code>/); + // vs multi-user. There's no mode pill or features list in the seeded + // HTML — those would go stale on the next mode change because of + // skip-if-exists. Mode/feature differences land in the buttons, which + // adapt at load time via the HEAD probe (covered below). + it('renders the same copy regardless of any context flags', () => { + const a = renderServerRoot({ version: '1.0.0', singleUser: true }); + const b = renderServerRoot({ version: '1.0.0', singleUser: false }); + // Drop the only varying value (the version, identical here) and + // assert byte-equality across the two renders. + assert.strictEqual(a, b); + assert.match(a, /<h1>Welcome<\/h1>/); + assert.match(a, /Your JSS Solid pod is running/); + assert.match(a, /open standard for personal data/); + }); + + it('does not bake mode or feature pills into the seeded HTML', () => { + // These would go stale: the seed is skip-if-exists, so a mode + // change after first start wouldn't re-render them. Excluded + // from the seed; the CLI banner already lists them at startup. + const html = renderServerRoot({ version: '1.0.0', singleUser: true, enabled: { idp: true, nostr: true } }); + assert.doesNotMatch(html, /<code>single-user<\/code>/); + assert.doesNotMatch(html, /<span>idp<\/span>/); + assert.doesNotMatch(html, /<span>nostr<\/span>/); }); it('always emits the Get started button pointing at the docs introduction', () => { @@ -134,20 +141,25 @@ describe('renderServerRoot', () => { it('emits Sign up + Sign in buttons hidden for the HEAD probe to reveal', () => { const html = renderServerRoot({ version: '1.0.0' }); - // Both anchors are present in every mode; the inline script reveals - // them based on what /idp/register actually returns. assert.match(html, /<a href="\/idp\/register"[^>]*data-cond="register"[^>]*hidden/); assert.match(html, /<a href="\/idp"[^>]*data-cond="login"[^>]*hidden/); assert.match(html, /Sign up/); assert.match(html, /Sign in/); }); + it('overrides the .btn display rule for the [hidden] attribute so the buttons actually start hidden', () => { + // Without an explicit !important [hidden] rule, the .btn class's + // display:inline-block beats the UA stylesheet's [hidden]{display:none} + // and the Sign up / Sign in anchors flash visible before the HEAD probe + // finishes. The CSS rule is the load-bearing piece; assert it's there. + const html = renderServerRoot({ version: '1.0.0' }); + assert.match(html, /\[hidden\]\s*\{[^}]*display:\s*none\s*!important/); + }); + it('includes the HEAD-adaptive script targeting /idp/register', () => { const html = renderServerRoot({ version: '1.0.0' }); assert.match(html, /fetch\(['"]\/idp\/register['"]/); assert.match(html, /method:\s*['"]HEAD['"]/); - // The three documented branches: 200 → both, 403 → login only, - // anything else → neither. Assert the magic numbers are present. assert.match(html, /res\.status === 200/); assert.match(html, /res\.status === 403/); }); @@ -158,17 +170,6 @@ describe('renderServerRoot', () => { assert.match(html, /window\.location\.origin/); }); - it('lists enabled features as pills', () => { - const html = renderServerRoot({ - version: '1.0.0', - enabled: { idp: true, nostr: true, webrtc: true, terminal: true } - }); - assert.match(html, /<span>idp<\/span>/); - assert.match(html, /<span>nostr<\/span>/); - assert.match(html, /<span>webrtc<\/span>/); - assert.match(html, /<span>terminal<\/span>/); - }); - it('interpolates version into the info box', () => { const html = renderServerRoot({ version: '9.9.9' }); assert.match(html, /<code>9\.9\.9<\/code>/); @@ -187,3 +188,45 @@ describe('renderServerRoot', () => { assert.match(html, /<code>\/index\.html<\/code>/); }); }); + +// Pure-function unit tests for the HEAD response → button-reveal matrix. +// The inline script in server-root.html implements the same matrix by +// hand; a regex check on the script text (above) catches outright drops +// of the literals, but only this helper test pins down the *behaviour* +// of the matrix without needing a DOM. +describe('decideRevealForRegisterStatus', () => { + it('reveals both Sign up and Sign in for HTTP 200 (registration open)', () => { + assert.deepStrictEqual( + decideRevealForRegisterStatus(200), + { register: true, login: true } + ); + }); + + it('reveals only Sign in for HTTP 403 (single-user — registration disabled)', () => { + assert.deepStrictEqual( + decideRevealForRegisterStatus(403), + { register: false, login: true } + ); + }); + + it('reveals neither for HTTP 404 (no IDP)', () => { + assert.deepStrictEqual( + decideRevealForRegisterStatus(404), + { register: false, login: false } + ); + }); + + it('reveals neither for any other status (e.g. 500)', () => { + assert.deepStrictEqual( + decideRevealForRegisterStatus(500), + { register: false, login: false } + ); + }); + + it('reveals neither when status is undefined (network error)', () => { + assert.deepStrictEqual( + decideRevealForRegisterStatus(undefined), + { register: false, login: false } + ); + }); +}); From 45b695c3f2832380865e996c9c489280aa2a9ee6 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho <melvincarvalho@gmail.com> Date: Thu, 14 May 2026 09:25:58 +0200 Subject: [PATCH 4/7] review: fix issue reference in seeded HTML comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dropped-pills explanatory comment pointed at '#436 review' but that's this PR, not the issue. The issue being closed is #435 — update the reference so the rationale is traceable from the seeded file. --- src/ui/server-root.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/server-root.html b/src/ui/server-root.html index b154282..4938c56 100644 --- a/src/ui/server-root.html +++ b/src/ui/server-root.html @@ -73,7 +73,7 @@ <h1>Welcome</h1> <!-- Mode and enabled-feature pills used to live here, but the seeded HTML can't reflect mode changes after first start (skip-if-exists). The CLI banner already lists both at startup; surface them via a - runtime probe in a follow-up if needed. See #436 review. --> + runtime probe in a follow-up if needed. See #435. --> <footer> From b6ad3968fb004a49b673166b7be078f9118ba1a4 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho <melvincarvalho@gmail.com> Date: Thu, 14 May 2026 09:32:34 +0200 Subject: [PATCH 5/7] review: page-relative IdP URLs for path-prefix portability Address Copilot pickup on #436. The Sign up / Sign in anchor hrefs and the inline HEAD probe used origin-root absolute paths (/idp...). Behind a reverse proxy at e.g. https://example/jss/, these would escape the mount point and hit the proxy origin (/idp) instead of the JSS-mounted server (/jss/idp). Switch all three to page-relative (./idp...). Anchor hrefs and the fetch() URL both resolve against document.baseURI, so they stay inside whatever path prefix the server is mounted under. Same rationale as the relative ACL targets from #428. Tests assert both the new ./ form and the absence of the old absolute form (so a regression to /idp would fail loud). 819/819 tests pass. --- src/ui/server-root.html | 13 ++++++++++--- test/server-root.test.js | 20 +++++++++++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/ui/server-root.html b/src/ui/server-root.html index 4938c56..ba48959 100644 --- a/src/ui/server-root.html +++ b/src/ui/server-root.html @@ -63,8 +63,13 @@ <h1>Welcome</h1> <div class="actions"> <a href="https://jss.live/docs/getting-started/introduction" class="btn btn-primary">Get started →</a> - <a href="/idp/register" class="btn btn-secondary" data-cond="register" hidden>Sign up</a> - <a href="/idp" class="btn btn-secondary" data-cond="login" hidden>Sign in</a> + <!-- IdP links are written page-relative (no leading slash) so they + resolve against document base, surviving reverse-proxy mounts + at a path prefix (e.g. https://example/jss/). Origin-root + absolute paths like /idp would jump out of the mount point. + Same rationale as the relative ACL targets from #428. --> + <a href="./idp/register" class="btn btn-secondary" data-cond="register" hidden>Sign up</a> + <a href="./idp" class="btn btn-secondary" data-cond="login" hidden>Sign in</a> </div> <div class="info"> @@ -103,7 +108,9 @@ <h1>Welcome</h1> // Sign in only // 404 / network error → no IDP: hide both (function () { - fetch('/idp/register', { method: 'HEAD', cache: 'no-store' }) + // Relative URL resolves against document base, same as the + // anchor hrefs above — preserves the path-prefix mount point. + fetch('./idp/register', { method: 'HEAD', cache: 'no-store' }) .then(function (res) { var register = document.querySelector('[data-cond="register"]'); var login = document.querySelector('[data-cond="login"]'); diff --git a/test/server-root.test.js b/test/server-root.test.js index aa8a18b..511372c 100644 --- a/test/server-root.test.js +++ b/test/server-root.test.js @@ -139,10 +139,17 @@ describe('renderServerRoot', () => { assert.match(html, /Get started/); }); - it('emits Sign up + Sign in buttons hidden for the HEAD probe to reveal', () => { + it('emits Sign up + Sign in buttons hidden for the HEAD probe to reveal, with page-relative hrefs', () => { const html = renderServerRoot({ version: '1.0.0' }); - assert.match(html, /<a href="\/idp\/register"[^>]*data-cond="register"[^>]*hidden/); - assert.match(html, /<a href="\/idp"[^>]*data-cond="login"[^>]*hidden/); + // Page-relative (./idp/...) so a reverse-proxy mount under a path + // prefix sends visitors into the correct prefix instead of the + // origin root. Same rationale as the ./ ACL targets from #428. + assert.match(html, /<a href="\.\/idp\/register"[^>]*data-cond="register"[^>]*hidden/); + assert.match(html, /<a href="\.\/idp"[^>]*data-cond="login"[^>]*hidden/); + assert.doesNotMatch(html, /<a href="\/idp\/register"/, + 'Sign up href must be page-relative for path-prefix portability'); + assert.doesNotMatch(html, /<a href="\/idp"/, + 'Sign in href must be page-relative for path-prefix portability'); assert.match(html, /Sign up/); assert.match(html, /Sign in/); }); @@ -156,9 +163,12 @@ describe('renderServerRoot', () => { assert.match(html, /\[hidden\]\s*\{[^}]*display:\s*none\s*!important/); }); - it('includes the HEAD-adaptive script targeting /idp/register', () => { + it('includes the HEAD-adaptive script targeting ./idp/register (page-relative)', () => { const html = renderServerRoot({ version: '1.0.0' }); - assert.match(html, /fetch\(['"]\/idp\/register['"]/); + // Same path-prefix portability concern as the anchor hrefs. + assert.match(html, /fetch\(['"]\.\/idp\/register['"]/); + assert.doesNotMatch(html, /fetch\(['"]\/idp\/register['"]/, + 'HEAD probe URL must be page-relative for path-prefix portability'); assert.match(html, /method:\s*['"]HEAD['"]/); assert.match(html, /res\.status === 200/); assert.match(html, /res\.status === 403/); From 5447f65f61c1be6fb877c5f3a4ec33b83b94e5ee Mon Sep 17 00:00:00 2001 From: Melvin Carvalho <melvincarvalho@gmail.com> Date: Thu, 14 May 2026 09:39:35 +0200 Subject: [PATCH 6/7] review: derive live URL from document base, not origin-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot pickup on #436. window.location.origin drops any reverse-proxy path prefix the server is mounted under: a JSS instance at https://example.com/jss/ would have displayed (and linked to) 'https://example.com/' for the live-URL line, escaping the mount. Switch to new URL('./', window.location.href).href, which resolves to the directory of the current document — i.e. the JSS server root, prefix included. Same path-prefix-safe treatment as the IdP links and ACL targets in the rest of this PR. Test asserts both presence of the new pattern and absence of the origin-only form. 819/819 tests pass. --- src/ui/server-root.html | 16 ++++++++++------ test/server-root.test.js | 9 +++++++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/ui/server-root.html b/src/ui/server-root.html index ba48959..055e50b 100644 --- a/src/ui/server-root.html +++ b/src/ui/server-root.html @@ -88,15 +88,19 @@ <h1>Welcome</h1> </div> <script> - // Render the live server URL the visitor actually used (adapts to - // whichever hostname/port they hit — works behind reverse proxies - // and across localhost / 127.0.0.1 / LAN-IP without re-rendering - // the file). + // Render the live server URL the visitor actually used. Adapts to + // whichever hostname/port they hit (localhost / 127.0.0.1 / LAN-IP + // / public domain) and — importantly — preserves any reverse-proxy + // path prefix the server is mounted under. `window.location.origin` + // alone would drop a /jss/ mount and display the proxy origin + // instead. `new URL('./', window.location.href)` resolves to the + // current document's directory, which is the JSS server root. (function () { var el = document.getElementById('server-url'); if (!el) return; - el.textContent = window.location.origin; - el.href = window.location.origin + '/'; + var base = new URL('./', window.location.href).href; + el.textContent = base; + el.href = base; })(); // HEAD-adaptive Sign up / Sign in. Reveals each button based on what diff --git a/test/server-root.test.js b/test/server-root.test.js index 511372c..4ee00ce 100644 --- a/test/server-root.test.js +++ b/test/server-root.test.js @@ -174,10 +174,15 @@ describe('renderServerRoot', () => { assert.match(html, /res\.status === 403/); }); - it('includes the live-URL script that fills in window.location.origin', () => { + it('includes the live-URL script that resolves the document base, not just origin', () => { const html = renderServerRoot({ version: '1.0.0' }); assert.match(html, /id="server-url"/); - assert.match(html, /window\.location\.origin/); + // Must use new URL('./', window.location.href) so a reverse-proxy + // mount at a path prefix is preserved. window.location.origin alone + // would drop the prefix and display the proxy origin instead. + assert.match(html, /new URL\(['"]\.\/['"],\s*window\.location\.href\)/); + assert.doesNotMatch(html, /textContent\s*=\s*window\.location\.origin/, + 'live-URL must not display origin-only — it would drop the path prefix on reverse-proxy mounts'); }); it('interpolates version into the info box', () => { From 490355323f7a3d257beaf7bddc41dcc0c7047983 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho <melvincarvalho@gmail.com> Date: Thu, 14 May 2026 09:49:18 +0200 Subject: [PATCH 7/7] review: drop seeded version + page-relative fallback href MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more Copilot pickups on #436. 1. The version was still seed-time. Same skip-if-exists trap as the mode and feature pills already dropped — an upgrade wouldn't refresh the displayed version. Operators relying on it would silently see the old number forever. Drop the version row entirely; the CLI banner already shows it at startup, and `npm view` is one shell command away. With version gone, the info box has nothing left to show; remove the whole .info block plus its CSS. The renderer no longer has any dynamic values to substitute — collapses to a static readFileSync. Drop the template-substitution machinery (single-pass replacer) and the escape() helper that only existed to sanitise version. 2. The live-URL anchor's fallback href was "/" (origin-root absolute). If the inline script doesn't run (CSP, JS disabled, error before it executes), clicking the link would escape any reverse-proxy mount prefix. Switch to "./" so the fallback stays inside the mount, matching every other URL in this template. Tests: dropped the version interpolation + injection-escape tests (no longer applicable — no version is rendered, no values substituted). Renamed the byte-equality test to make the static-HTML guarantee explicit. Added a regression test asserting the page-relative fallback href. 819/819 tests pass. --- src/ui/server-root.html | 24 +++++++++---------- src/ui/server-root.js | 49 +++++++++++---------------------------- test/server-root.test.js | 50 +++++++++++++++++++++------------------- 3 files changed, 51 insertions(+), 72 deletions(-) diff --git a/src/ui/server-root.html b/src/ui/server-root.html index 055e50b..f5733cc 100644 --- a/src/ui/server-root.html +++ b/src/ui/server-root.html @@ -3,7 +3,7 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{{title}} + JSS Solid pod