diff --git a/src/server.js b/src/server.js
index 548cf94..47f04ed 100644
--- a/src/server.js
+++ b/src/server.js
@@ -28,6 +28,7 @@ import { webrtcPlugin } from './webrtc/index.js';
import { tunnelPlugin } from './tunnel/index.js';
import { terminalPlugin } from './terminal/index.js';
import { registerErrorHandler } from './utils/error-handler.js';
+import { seedServerRoot } from './ui/server-root.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -762,6 +763,48 @@ export function createServer(options = {}) {
fastify.options('/', handleOptions);
fastify.post('/', writeRateLimit, handlePost);
+ // Server-root landing page: seed /index.html and a public-read /.acl
+ // on first start (skip-if-exists, so operator-provided files are
+ // preserved). See #433 / #276. Skipped in read-only deployments so
+ // startup never mutates DATA_ROOT.
+ if (!options.readOnly) {
+ fastify.addHook('onReady', async () => {
+ // A missing or unreadable package.json (some production bundles
+ // omit it) shouldn't block seeding; fall back to "unknown".
+ let version = 'unknown';
+ try {
+ const pkg = await readFile(join(__dirname, '..', 'package.json'), 'utf8');
+ ({ 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,
+ idp: idpEnabled,
+ singleUserName,
+ enabled: {
+ idp: idpEnabled,
+ nostr: nostrEnabled,
+ webrtc: webrtcEnabled,
+ activitypub: activitypubEnabled,
+ git: gitEnabled,
+ pay: payEnabled,
+ notifications: notificationsEnabled,
+ mashlib: mashlibEnabled,
+ mongo: mongoEnabled,
+ tunnel: tunnelEnabled,
+ terminal: terminalEnabled
+ }
+ });
+ } 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) {
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}}
+
+
+
+
+
+
diff --git a/src/ui/server-root.js b/src/ui/server-root.js
new file mode 100644
index 0000000..d406163
--- /dev/null
+++ b/src/ui/server-root.js
@@ -0,0 +1,173 @@
+/**
+ * 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';
+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;
+}
+
+/**
+ * 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.';
+
+ // 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.
+ const values = {
+ title: heading,
+ heading,
+ subtitle,
+ description,
+ actions: renderActions({ singleUser, idp }),
+ version: escape(version),
+ mode,
+ features
+ };
+ return tpl.replace(/{{(\w+)}}/g, (match, key) =>
+ Object.prototype.hasOwnProperty.call(values, key) ? values[key] : match
+ );
+}
+
+function escape(s = '') {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+
+/**
+ * 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.
+ *
+ * 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<{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);
+ 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;
+ }
+
+ // Seed /.acl if one doesn't already exist. Public read on the container
+ // itself — so GET / serves the landing page. Independent of index.html.
+ //
+ // Use './' (relative to the .acl's own URL) rather than '/' (the
+ // origin root). The two coincide when JSS is mounted at the origin
+ // root, but only the relative form survives reverse-proxy mounts at
+ // a path prefix (e.g. https://example/jss/). This matches the
+ // pattern used by createPodStructure / createRootPodStructure since
+ // #428 / #430.
+ //
+ // (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', serializeAcl(generatePublicReadAcl('./')));
+ 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.
+ // Same relative-form rationale as above.
+ if (!(await storage.exists('/index.html.acl'))) {
+ const ok = await storage.write('/index.html.acl', serializeAcl(generatePublicReadAcl('./index.html')));
+ if (ok) seededPageAcl = true;
+ }
+
+ return { seededHtml, seededAcl, seededPageAcl };
+}
diff --git a/test/server-root.test.js b/test/server-root.test.js
new file mode 100644
index 0000000..1ae701c
--- /dev/null
+++ b/test/server-root.test.js
@@ -0,0 +1,192 @@
+/**
+ * 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 { renderServerRoot } from '../src/ui/server-root.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);
+ });
+
+ // Portability regression: the seeded ACLs must use './' (resolved
+ // against the .acl's own URL) rather than '/' (the origin root).
+ // The two coincide when JSS sits at the origin root, so a request
+ // smoke-test would pass either way; only direct inspection of the
+ // serialized accessTo catches a regression to the absolute form.
+ // Without this, JSS mounted under a reverse-proxy path prefix would
+ // see the seeded ACL match the origin root rather than the prefix.
+ it('seeded ACLs use relative resourceUrl ("./" / "./index.html"), not absolute paths', async () => {
+ const rootAcl = JSON.parse(await fs.readFile('./data/.acl', 'utf8'));
+ const pageAcl = JSON.parse(await fs.readFile('./data/index.html.acl', 'utf8'));
+ const rootAccessTo = rootAcl['@graph'][0]['acl:accessTo']['@id'];
+ const pageAccessTo = pageAcl['@graph'][0]['acl:accessTo']['@id'];
+ assert.strictEqual(rootAccessTo, './',
+ `Expected /.acl accessTo to be relative './', got '${rootAccessTo}'`);
+ assert.strictEqual(pageAccessTo, './index.html',
+ `Expected /index.html.acl accessTo to be relative './index.html', got '${pageAccessTo}'`);
+ });
+});
+
+// Operator's existing /index.html is preserved — dedicated server + data dir.
+describe('Server-root landing — operator override', () => {
+ let server;
+ let baseUrl;
+ let savedDataRoot;
+ const DATA_DIR = './test-data-server-root-override';
+ const CUSTOM_HTML = 'my custom page';
+
+ 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);
+
+ 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);
+ if (savedDataRoot === undefined) delete process.env.DATA_ROOT;
+ else process.env.DATA_ROOT = savedDataRoot;
+ });
+
+ 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/);
+ });
+});
+
+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, /idp<\/span>/);
+ assert.match(html, /nostr<\/span>/);
+ assert.match(html, /webrtc<\/span>/);
+ assert.match(html, /terminal<\/span>/);
+ });
+
+ it('interpolates version', () => {
+ const html = renderServerRoot({ version: '9.9.9' });
+ assert.match(html, /9\.9\.9<\/code>/);
+ });
+
+ it('escapes version to prevent injection', () => {
+ const html = renderServerRoot({ version: '' });
+ assert.doesNotMatch(html, /