From dcf8a91c58c297b593231f433fa97b8bc81dfe42 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 23 Apr 2026 00:32:55 +0200 Subject: [PATCH 1/5] feat: default landing page + ACL at server root (#276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On server startup, seed DATA_ROOT/index.html with a minimal landing page and DATA_ROOT/.acl + DATA_ROOT/index.html.acl with public-read ACLs. Skip-if-exists — operator-provided files are preserved. Landing page adapts to server mode (multi-user + IDP shows Create Pod / Sign in; single-user shows pod info). Lists enabled features. For v1, the root is public-read only — no public write. Operators edit /index.html on disk. A --admin-webid flag could relax this in a future iteration. Closes #276 --- src/server.js | 30 ++++++++ src/ui/server-root.html | 70 +++++++++++++++++ src/ui/server-root.js | 161 +++++++++++++++++++++++++++++++++++++++ test/server-root.test.js | 71 +++++++++++++++++ 4 files changed, 332 insertions(+) create mode 100644 src/ui/server-root.html create mode 100644 src/ui/server-root.js create mode 100644 test/server-root.test.js diff --git a/src/server.js b/src/server.js index c914b74..933ba59 100644 --- a/src/server.js +++ b/src/server.js @@ -21,6 +21,7 @@ import { dbPlugin } from './db/index.js'; import { webrtcPlugin } from './webrtc/index.js'; import { tunnelPlugin } from './tunnel/index.js'; import { terminalPlugin } from './terminal/index.js'; +import { seedServerRoot } from './ui/server-root.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -545,6 +546,35 @@ export function createServer(options = {}) { fastify.options('/', handleOptions); fastify.post('/', writeRateLimit, handlePost); + // Server-root landing page: seed /index.html and /.acl on first start + // (skip-if-exists, operator customisations preserved). See #276. + fastify.addHook('onReady', async () => { + try { + const pkg = await readFile(join(__dirname, '..', 'package.json'), 'utf8'); + const { version } = JSON.parse(pkg); + await seedServerRoot({ + version, + singleUser, + idp: idpEnabled, + singleUserName, + enabled: { + idp: idpEnabled, + nostr: nostrEnabled, + webrtc: webrtcEnabled, + activitypub: activitypubEnabled, + git: gitEnabled, + pay: payEnabled, + notifications: notificationsEnabled, + mashlib: mashlibEnabled, + mongo: mongoEnabled, + tunnel: tunnelEnabled + } + }); + } catch (err) { + fastify.log.warn(`Failed to seed server root: ${err.message}`); + } + }); + // Single-user mode: create pod on startup if it doesn't exist if (singleUser) { fastify.addHook('onReady', async () => { diff --git a/src/ui/server-root.html b/src/ui/server-root.html new file mode 100644 index 0000000..cbcc35e --- /dev/null +++ b/src/ui/server-root.html @@ -0,0 +1,70 @@ + + + + + + {{title}} + + + + +
+

{{heading}}

+
{{subtitle}}
+

{{description}}

+ + {{actions}} + +
+
Version{{version}}
+
Mode{{mode}}
+
{{features}}
+
+ +
+ Powered by JSS +
+
+ + diff --git a/src/ui/server-root.js b/src/ui/server-root.js new file mode 100644 index 0000000..c3e04f7 --- /dev/null +++ b/src/ui/server-root.js @@ -0,0 +1,161 @@ +/** + * Server-root landing page. + * + * Renders src/ui/server-root.html with runtime values, and seeds + * DATA_ROOT/index.html + DATA_ROOT/.acl on first start (skip-if-exists, + * so operator customisation is preserved). + * + * See issue #276. + */ + +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import * as storage from '../storage/filesystem.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'); + 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. + * + * @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 + * @returns {string} HTML + */ +export function renderServerRoot(ctx = {}) { + const { version = 'unknown', singleUser = false, idp = false, singleUserName, enabled = {} } = ctx; + + const tpl = readFileSync(TEMPLATE_PATH, 'utf8'); + const mode = singleUser ? 'single-user' : 'multi-user'; + const features = listFeatures(enabled) + .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.'; + + return tpl + .replace(/{{title}}/g, heading) + .replace(/{{heading}}/g, heading) + .replace(/{{subtitle}}/g, subtitle) + .replace(/{{description}}/g, description) + .replace(/{{actions}}/g, renderActions({ singleUser, idp })) + .replace(/{{version}}/g, escape(version)) + .replace(/{{mode}}/g, mode) + .replace(/{{features}}/g, features); +} + +function escape(s = '') { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** + * Seed DATA_ROOT/index.html and DATA_ROOT/.acl if they don't already + * exist. Operator's own files are never overwritten. + * + * Default ACL at root: public read. Write access is not granted — the + * operator edits the file on disk, not via the web. + * + * @param {object} ctx - Same context passed to renderServerRoot + * @returns {Promise<{seeded: boolean}>} + */ +export async function seedServerRoot(ctx = {}) { + let seededHtml = false; + let seededAcl = false; + + // Seed /index.html if operator hasn't written one. + if (!(await storage.exists('/index.html'))) { + const html = renderServerRoot(ctx); + await storage.write('/index.html', html); + seededHtml = true; + } + + // Seed /.acl if one doesn't already exist. Public read on the container + // itself — so GET / serves the landing page. Independent of index.html. + // (createRootPodStructure in single-user mode writes its own ACL and + // runs in a later hook, which will overwrite this if needed.) + if (!(await storage.exists('/.acl'))) { + const acl = JSON.stringify({ + '@context': { acl: 'http://www.w3.org/ns/auth/acl#', foaf: 'http://xmlns.com/foaf/0.1/' }, + '@graph': [ + { + '@id': '#public', + '@type': 'acl:Authorization', + 'acl:agentClass': { '@id': 'foaf:Agent' }, + 'acl:accessTo': { '@id': '/' }, + 'acl:mode': [{ '@id': 'acl:Read' }] + } + ] + }, null, 2); + await storage.write('/.acl', acl); + seededAcl = true; + } + + // Dedicated ACL for the landing page itself — public read. The container + // ACL above has no acl:default (we don't want to implicitly publish all + // children), so /index.html needs its own rule when fetched directly. + if (!(await storage.exists('/index.html.acl'))) { + const pageAcl = JSON.stringify({ + '@context': { acl: 'http://www.w3.org/ns/auth/acl#', foaf: 'http://xmlns.com/foaf/0.1/' }, + '@graph': [ + { + '@id': '#public', + '@type': 'acl:Authorization', + 'acl:agentClass': { '@id': 'foaf:Agent' }, + 'acl:accessTo': { '@id': '/index.html' }, + 'acl:mode': [{ '@id': 'acl:Read' }] + } + ] + }, null, 2); + await storage.write('/index.html.acl', pageAcl); + } + + return { seededHtml, seededAcl }; +} diff --git a/test/server-root.test.js b/test/server-root.test.js new file mode 100644 index 0000000..5a1ae33 --- /dev/null +++ b/test/server-root.test.js @@ -0,0 +1,71 @@ +/** + * Server-root landing page seed (#276). + */ + +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 { startTestServer, stopTestServer, request, assertStatus } from './helpers.js'; + +describe('Server-root landing page', () => { + before(async () => { + await startTestServer(); + }); + + after(async () => { + await stopTestServer(); + }); + + it('seeds /index.html so GET / serves HTML', async () => { + 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/); + }); + + it('landing page is publicly readable (no auth required)', async () => { + const res = await request('/index.html'); + assertStatus(res, 200); + }); +}); + +// Operator's existing /index.html is preserved — dedicated server + data dir. +describe('Server-root landing — operator override', () => { + let server; + let baseUrl; + const DATA_DIR = './test-data-server-root-override'; + const CUSTOM_HTML = '<!doctype html><html><body>my custom page</body></html>'; + + before(async () => { + await fs.remove(DATA_DIR); + await fs.ensureDir(DATA_DIR); + await fs.writeFile(`${DATA_DIR}/index.html`, CUSTOM_HTML); + + server = createServer({ + logger: false, + root: DATA_DIR, + forceCloseConnections: true, + }); + await server.listen({ port: 0, host: '127.0.0.1' }); + baseUrl = `http://127.0.0.1:${server.server.address().port}`; + }); + + after(async () => { + await server.close(); + await fs.remove(DATA_DIR); + }); + + it('does not overwrite operator-provided /index.html', async () => { + const current = await fs.readFile(`${DATA_DIR}/index.html`, 'utf8'); + assert.strictEqual(current, CUSTOM_HTML); + }); + + it('GET / serves operator custom page', async () => { + const res = await fetch(`${baseUrl}/`, { headers: { Accept: 'text/html' } }); + assert.strictEqual(res.status, 200); + const body = await res.text(); + assert.match(body, /my custom page/); + }); +}); From 020aff9cd33d23bf49cd7e51a7d9aad00620008b Mon Sep 17 00:00:00 2001 From: Melvin Carvalho <melvincarvalho@gmail.com> Date: Thu, 23 Apr 2026 01:22:26 +0200 Subject: [PATCH 2/5] fix: check storage.write returns, use real IDP routes, log full error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - storage.write returns bool; abort ACL seeding if index.html write fails - Fix dead links: /idp/register for signup, /idp for sign in (/.account/new doesn't exist in JSS) - Log full error object (with stack) instead of just message - Factor publicReadAcl() helper (owner-less — the root has no owner) - JSDoc return type matches actual shape --- src/server.js | 2 +- src/ui/server-root.js | 81 +++++++++++++++++++++++-------------------- 2 files changed, 45 insertions(+), 38 deletions(-) diff --git a/src/server.js b/src/server.js index 933ba59..4825d66 100644 --- a/src/server.js +++ b/src/server.js @@ -571,7 +571,7 @@ export function createServer(options = {}) { } }); } catch (err) { - fastify.log.warn(`Failed to seed server root: ${err.message}`); + fastify.log.warn({ err }, 'Failed to seed server root'); } }); diff --git a/src/ui/server-root.js b/src/ui/server-root.js index c3e04f7..e321fe2 100644 --- a/src/ui/server-root.js +++ b/src/ui/server-root.js @@ -40,10 +40,10 @@ function listFeatures(options = {}) { function renderActions({ singleUser, idp }) { const buttons = []; if (!singleUser && idp) { - buttons.push('<a href="/.account/new" class="btn btn-primary">Create a pod</a>'); - buttons.push('<a href="/idp/auth" class="btn btn-secondary">Sign in</a>'); + buttons.push('<a href="/idp/register" class="btn btn-primary">Create a pod</a>'); + buttons.push('<a href="/idp" class="btn btn-secondary">Sign in</a>'); } else if (singleUser && idp) { - buttons.push('<a href="/idp/auth" class="btn btn-primary">Sign in</a>'); + buttons.push('<a href="/idp" class="btn btn-primary">Sign in</a>'); } buttons.push('<a href="https://javascriptsolidserver.github.io/docs/" class="btn btn-secondary">Docs</a>'); return `<div class="actions">${buttons.join('\n ')}</div>`; @@ -97,23 +97,53 @@ function escape(s = '') { } /** - * Seed DATA_ROOT/index.html and DATA_ROOT/.acl if they don't already - * exist. Operator's own files are never overwritten. + * Build a public-read-only ACL (no owner) for the server root. + * The existing WAC generators all require an owner WebID; the server + * root has none, so this is a tiny local helper. + */ +function publicReadAcl(target) { + return JSON.stringify({ + '@context': { acl: 'http://www.w3.org/ns/auth/acl#', foaf: 'http://xmlns.com/foaf/0.1/' }, + '@graph': [ + { + '@id': '#public', + '@type': 'acl:Authorization', + 'acl:agentClass': { '@id': 'foaf:Agent' }, + 'acl:accessTo': { '@id': target }, + 'acl:mode': [{ '@id': 'acl:Read' }] + } + ] + }, null, 2); +} + +/** + * Seed DATA_ROOT/index.html, DATA_ROOT/.acl and DATA_ROOT/index.html.acl + * if they don't already exist. Operator's own files are never overwritten. + * + * Default ACL: public read. No write access — the operator edits + * /index.html on disk, not via the web. * - * Default ACL at root: public read. Write access is not granted — the - * operator edits the file on disk, not via the web. + * If the HTML write fails (permissions, full disk, read-only DATA_ROOT), + * ACL seeding is aborted to avoid leaving the server with a public-read + * root ACL and no index page. * * @param {object} ctx - Same context passed to renderServerRoot - * @returns {Promise<{seeded: boolean}>} + * @returns {Promise<{seededHtml: boolean, seededAcl: boolean, seededPageAcl: boolean}>} */ export async function seedServerRoot(ctx = {}) { let seededHtml = false; let seededAcl = false; + let seededPageAcl = false; // Seed /index.html if operator hasn't written one. if (!(await storage.exists('/index.html'))) { const html = renderServerRoot(ctx); - await storage.write('/index.html', html); + const ok = await storage.write('/index.html', html); + if (!ok) { + // Don't proceed with ACLs if the page itself failed to write — + // leaves us in a consistent unchanged state. + return { seededHtml: false, seededAcl: false, seededPageAcl: false }; + } seededHtml = true; } @@ -122,40 +152,17 @@ export async function seedServerRoot(ctx = {}) { // (createRootPodStructure in single-user mode writes its own ACL and // runs in a later hook, which will overwrite this if needed.) if (!(await storage.exists('/.acl'))) { - const acl = JSON.stringify({ - '@context': { acl: 'http://www.w3.org/ns/auth/acl#', foaf: 'http://xmlns.com/foaf/0.1/' }, - '@graph': [ - { - '@id': '#public', - '@type': 'acl:Authorization', - 'acl:agentClass': { '@id': 'foaf:Agent' }, - 'acl:accessTo': { '@id': '/' }, - 'acl:mode': [{ '@id': 'acl:Read' }] - } - ] - }, null, 2); - await storage.write('/.acl', acl); - seededAcl = true; + const ok = await storage.write('/.acl', publicReadAcl('/')); + if (ok) seededAcl = true; } // Dedicated ACL for the landing page itself — public read. The container // ACL above has no acl:default (we don't want to implicitly publish all // children), so /index.html needs its own rule when fetched directly. if (!(await storage.exists('/index.html.acl'))) { - const pageAcl = JSON.stringify({ - '@context': { acl: 'http://www.w3.org/ns/auth/acl#', foaf: 'http://xmlns.com/foaf/0.1/' }, - '@graph': [ - { - '@id': '#public', - '@type': 'acl:Authorization', - 'acl:agentClass': { '@id': 'foaf:Agent' }, - 'acl:accessTo': { '@id': '/index.html' }, - 'acl:mode': [{ '@id': 'acl:Read' }] - } - ] - }, null, 2); - await storage.write('/index.html.acl', pageAcl); + const ok = await storage.write('/index.html.acl', publicReadAcl('/index.html')); + if (ok) seededPageAcl = true; } - return { seededHtml, seededAcl }; + return { seededHtml, seededAcl, seededPageAcl }; } From 77d0d80fdfb548a9ea011fbb09956391c8d2d946 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho <melvincarvalho@gmail.com> Date: Thu, 23 Apr 2026 01:32:13 +0200 Subject: [PATCH 3/5] fix: use existing generatePublicReadAcl, skip seed in read-only mode - Import generatePublicReadAcl + serializeAcl from wac/parser.js instead of inlining ACL JSON (it exists and does exactly what we need) - Skip the onReady seeding hook when options.readOnly is true so a read-only deployment does not mutate DATA_ROOT --- src/server.js | 53 +++++++++++++++++++++++-------------------- src/ui/server-root.js | 24 +++----------------- 2 files changed, 31 insertions(+), 46 deletions(-) diff --git a/src/server.js b/src/server.js index 4825d66..1f12e3d 100644 --- a/src/server.js +++ b/src/server.js @@ -548,32 +548,35 @@ export function createServer(options = {}) { // Server-root landing page: seed /index.html and /.acl on first start // (skip-if-exists, operator customisations preserved). See #276. - fastify.addHook('onReady', async () => { - try { - const pkg = await readFile(join(__dirname, '..', 'package.json'), 'utf8'); - const { version } = JSON.parse(pkg); - await seedServerRoot({ - version, - singleUser, - idp: idpEnabled, - singleUserName, - enabled: { + // Skipped in read-only mode — startup must not mutate DATA_ROOT. + if (!options.readOnly) { + fastify.addHook('onReady', async () => { + try { + const pkg = await readFile(join(__dirname, '..', 'package.json'), 'utf8'); + const { version } = JSON.parse(pkg); + await seedServerRoot({ + version, + singleUser, idp: idpEnabled, - nostr: nostrEnabled, - webrtc: webrtcEnabled, - activitypub: activitypubEnabled, - git: gitEnabled, - pay: payEnabled, - notifications: notificationsEnabled, - mashlib: mashlibEnabled, - mongo: mongoEnabled, - tunnel: tunnelEnabled - } - }); - } catch (err) { - fastify.log.warn({ err }, 'Failed to seed server root'); - } - }); + singleUserName, + enabled: { + idp: idpEnabled, + nostr: nostrEnabled, + webrtc: webrtcEnabled, + activitypub: activitypubEnabled, + git: gitEnabled, + pay: payEnabled, + notifications: notificationsEnabled, + mashlib: mashlibEnabled, + mongo: mongoEnabled, + tunnel: tunnelEnabled + } + }); + } catch (err) { + fastify.log.warn({ err }, 'Failed to seed server root'); + } + }); + } // Single-user mode: create pod on startup if it doesn't exist if (singleUser) { diff --git a/src/ui/server-root.js b/src/ui/server-root.js index e321fe2..3272c64 100644 --- a/src/ui/server-root.js +++ b/src/ui/server-root.js @@ -12,6 +12,7 @@ import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import * as storage from '../storage/filesystem.js'; +import { generatePublicReadAcl, serializeAcl } from '../wac/parser.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const TEMPLATE_PATH = join(__dirname, 'server-root.html'); @@ -96,25 +97,6 @@ function escape(s = '') { .replace(/"/g, '"'); } -/** - * Build a public-read-only ACL (no owner) for the server root. - * The existing WAC generators all require an owner WebID; the server - * root has none, so this is a tiny local helper. - */ -function publicReadAcl(target) { - return JSON.stringify({ - '@context': { acl: 'http://www.w3.org/ns/auth/acl#', foaf: 'http://xmlns.com/foaf/0.1/' }, - '@graph': [ - { - '@id': '#public', - '@type': 'acl:Authorization', - 'acl:agentClass': { '@id': 'foaf:Agent' }, - 'acl:accessTo': { '@id': target }, - 'acl:mode': [{ '@id': 'acl:Read' }] - } - ] - }, null, 2); -} /** * Seed DATA_ROOT/index.html, DATA_ROOT/.acl and DATA_ROOT/index.html.acl @@ -152,7 +134,7 @@ export async function seedServerRoot(ctx = {}) { // (createRootPodStructure in single-user mode writes its own ACL and // runs in a later hook, which will overwrite this if needed.) if (!(await storage.exists('/.acl'))) { - const ok = await storage.write('/.acl', publicReadAcl('/')); + const ok = await storage.write('/.acl', serializeAcl(generatePublicReadAcl('/'))); if (ok) seededAcl = true; } @@ -160,7 +142,7 @@ export async function seedServerRoot(ctx = {}) { // ACL above has no acl:default (we don't want to implicitly publish all // children), so /index.html needs its own rule when fetched directly. if (!(await storage.exists('/index.html.acl'))) { - const ok = await storage.write('/index.html.acl', publicReadAcl('/index.html')); + const ok = await storage.write('/index.html.acl', serializeAcl(generatePublicReadAcl('/index.html'))); if (ok) seededPageAcl = true; } From 0fe57afd8eba00069f25278d83d289ba415aa08e Mon Sep 17 00:00:00 2001 From: Melvin Carvalho <melvincarvalho@gmail.com> Date: Thu, 23 Apr 2026 01:48:47 +0200 Subject: [PATCH 4/5] fix: restore process.env.DATA_ROOT after tests, add terminal feature flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Operator-override test captures and restores process.env.DATA_ROOT — createServer({ root }) mutates it, which can leak to later test files. - listFeatures() now reports terminal alongside the other enabled flags. --- src/server.js | 3 ++- src/ui/server-root.js | 1 + test/server-root.test.js | 8 ++++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/server.js b/src/server.js index 1f12e3d..0c55db6 100644 --- a/src/server.js +++ b/src/server.js @@ -569,7 +569,8 @@ export function createServer(options = {}) { notifications: notificationsEnabled, mashlib: mashlibEnabled, mongo: mongoEnabled, - tunnel: tunnelEnabled + tunnel: tunnelEnabled, + terminal: terminalEnabled } }); } catch (err) { diff --git a/src/ui/server-root.js b/src/ui/server-root.js index 3272c64..5923945 100644 --- a/src/ui/server-root.js +++ b/src/ui/server-root.js @@ -32,6 +32,7 @@ function listFeatures(options = {}) { 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; } diff --git a/test/server-root.test.js b/test/server-root.test.js index 5a1ae33..92a495f 100644 --- a/test/server-root.test.js +++ b/test/server-root.test.js @@ -35,10 +35,16 @@ describe('Server-root landing page', () => { describe('Server-root landing — operator override', () => { let server; let baseUrl; + let savedDataRoot; const DATA_DIR = './test-data-server-root-override'; const CUSTOM_HTML = '<!doctype html><html><body>my custom page</body></html>'; before(async () => { + // Capture process.env.DATA_ROOT — createServer mutates it when options.root + // is provided. Restore in after() to avoid cross-test interference with + // suites that rely on the default ./data dir. + savedDataRoot = process.env.DATA_ROOT; + await fs.remove(DATA_DIR); await fs.ensureDir(DATA_DIR); await fs.writeFile(`${DATA_DIR}/index.html`, CUSTOM_HTML); @@ -55,6 +61,8 @@ describe('Server-root landing — operator override', () => { after(async () => { await server.close(); await fs.remove(DATA_DIR); + if (savedDataRoot === undefined) delete process.env.DATA_ROOT; + else process.env.DATA_ROOT = savedDataRoot; }); it('does not overwrite operator-provided /index.html', async () => { From f6b16fa89909266271372b2f988719f300edc43e Mon Sep 17 00:00:00 2001 From: Melvin Carvalho <melvincarvalho@gmail.com> Date: Thu, 23 Apr 2026 01:57:21 +0200 Subject: [PATCH 5/5] fix: resilient package.json read + unit tests for mode-specific rendering - Read version in its own try/catch, default to "unknown" on failure. A missing/unreadable package.json no longer blocks the landing page from being seeded (helps in bundled deployments without the file). - Add 7 unit tests for renderServerRoot covering multi-user+IDP, single-user+IDP, no-IDP, feature-list, version interpolation, and HTML-escape of injected version strings. --- src/server.js | 11 ++++++++- test/server-root.test.js | 53 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/server.js b/src/server.js index 0c55db6..ee96a48 100644 --- a/src/server.js +++ b/src/server.js @@ -551,9 +551,18 @@ export function createServer(options = {}) { // Skipped in read-only mode — startup must not mutate DATA_ROOT. if (!options.readOnly) { fastify.addHook('onReady', async () => { + // Read the version from package.json — a missing or unreadable file + // (some production bundles omit it) shouldn't block seeding; fall + // back to "unknown" and continue. + let version = 'unknown'; try { const pkg = await readFile(join(__dirname, '..', 'package.json'), 'utf8'); - const { version } = JSON.parse(pkg); + ({ version } = JSON.parse(pkg)); + } catch (err) { + fastify.log.warn({ err }, 'Failed to read package.json version; seeding server root with version=unknown'); + } + + try { await seedServerRoot({ version, singleUser, diff --git a/test/server-root.test.js b/test/server-root.test.js index 92a495f..5f67753 100644 --- a/test/server-root.test.js +++ b/test/server-root.test.js @@ -6,6 +6,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 { startTestServer, stopTestServer, request, assertStatus } from './helpers.js'; describe('Server-root landing page', () => { @@ -77,3 +78,55 @@ describe('Server-root landing — operator override', () => { assert.match(body, /my custom page/); }); }); + +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"/); + 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('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('lists enabled features', () => { + 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>/); + assert.match(html, /<span>nostr<\/span>/); + assert.match(html, /<span>webrtc<\/span>/); + assert.match(html, /<span>terminal<\/span>/); + }); + + it('interpolates version', () => { + const html = renderServerRoot({ version: '9.9.9' }); + assert.match(html, /<code>9\.9\.9<\/code>/); + }); + + it('escapes version to prevent injection', () => { + const html = renderServerRoot({ version: '<script>alert(1)</script>' }); + assert.doesNotMatch(html, /<script>alert\(1\)<\/script>/); + assert.match(html, /<script>/); + }); +});