From dce67b67b03e3982e000bebbecac30c7cad30a4c Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 14 May 2026 07:36:18 +0200 Subject: [PATCH 1/3] feat: default landing page + ACL at server root (#433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of #427. Supersedes the abandoned PR #303 (open since Apr 23, mergeable: CONFLICTING). On startup, seed DATA_ROOT/index.html, DATA_ROOT/.acl, and DATA_ROOT/index.html.acl with a minimal landing page and public-read ACLs. Skip-if-exists — operator files are never overwritten. Skipped entirely in read-only mode so startup never mutates DATA_ROOT. Landing page adapts to server mode: - multi-user + IDP → Create a pod / Sign in - single-user + IDP → Sign in - both → Docs link, version, mode, enabled-feature pills ACLs are portable by construction now that #428 / #430 made generatePublicReadAcl emit the resourceUrl verbatim. The seeded /.acl uses path-only "/" so it matches the request host, not whichever interface the server happened to bind on first start. Carries over the design + Copilot review fixes from PR #303: - generatePublicReadAcl reuse (no inline ACL JSON) - skip in read-only mode - check storage.write returns; abort ACL seeding on HTML write failure - real IDP routes (/idp/register, /idp — not /.account/new, /idp/auth) - full error logging (with stack) - terminal feature flag - resilient package.json read with version=unknown fallback - restore process.env.DATA_ROOT after operator-override test One additional fix flagged but not addressed on the original branch: String.prototype.replace with a string interprets `$&`, `$1`, etc. as substitution patterns, which would corrupt any interpolated value containing `$` (notably a singleUserName). Switched all template substitutions to the function form. Regression test asserts a singleUserName of "foo$&bar" survives intact. 811/811 tests pass. --- src/server.js | 43 +++++++++++ src/ui/server-root.html | 70 ++++++++++++++++++ src/ui/server-root.js | 156 +++++++++++++++++++++++++++++++++++++++ test/server-root.test.js | 152 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 421 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 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}}
+
+ +
+ Powered by JSS +
+
+ + diff --git a/src/ui/server-root.js b/src/ui/server-root.js new file mode 100644 index 0000000..1afadea --- /dev/null +++ b/src/ui/server-root.js @@ -0,0 +1,156 @@ +/** + * 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.'; + + // Replacements use the function form, not the string form: a string + // replacement interprets `$&`, `$1`, etc. as substitution patterns, + // which would corrupt any interpolated value containing a `$` (e.g. + // a single-user name). The function form skips that interpretation + // entirely. See #433. + 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, 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. + // (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. + 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..1ee4f12 --- /dev/null +++ b/test/server-root.test.js @@ -0,0 +1,152 @@ +/** + * 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); + }); +}); + +// 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 = '<!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); + + 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, /<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>/); + }); + + // 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'); + }); +}); From 3a45d17ae1f5e3dd8850706d2e5e1c10a7d444d1 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho <melvincarvalho@gmail.com> Date: Thu, 14 May 2026 07:53:24 +0200 Subject: [PATCH 2/3] review: seed ACLs with './' instead of '/' for path-prefix portability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review on #434. The previous commit seeded /.acl with generatePublicReadAcl('/') and /index.html.acl with generatePublicReadAcl('/index.html'). Both work when JSS sits at the origin root (the two coincide there), but '/' resolves to the origin root regardless of any path prefix the server is mounted under — so JSS behind a reverse proxy at e.g. https://example/jss/ would seed an ACL whose accessTo points at https://example/ rather than https://example/jss/. Switch to './' / './index.html' (resolved against the .acl's own URL), matching createPodStructure / createRootPodStructure since #428 / #430. Add a regression test that inspects the on-disk .acl files directly and asserts the relative form. A request smoke-test wouldn't catch this since both forms work when mounted at the origin root. 812/812 tests pass. --- src/ui/server-root.js | 13 +++++++++++-- test/server-root.test.js | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/ui/server-root.js b/src/ui/server-root.js index 1afadea..1ae6f99 100644 --- a/src/ui/server-root.js +++ b/src/ui/server-root.js @@ -137,18 +137,27 @@ export async function seedServerRoot(ctx = {}) { // 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('/'))); + 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'))); + const ok = await storage.write('/index.html.acl', serializeAcl(generatePublicReadAcl('./index.html'))); if (ok) seededPageAcl = true; } diff --git a/test/server-root.test.js b/test/server-root.test.js index 1ee4f12..39f38cf 100644 --- a/test/server-root.test.js +++ b/test/server-root.test.js @@ -30,6 +30,24 @@ describe('Server-root landing page', () => { 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. From 69b74bae770d9b6ca6845c1a455769be8f60d68c Mon Sep 17 00:00:00 2001 From: Melvin Carvalho <melvincarvalho@gmail.com> Date: Thu, 14 May 2026 08:03:22 +0200 Subject: [PATCH 3/3] review: single-pass token substitution to prevent value re-templating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review (2nd round) on #434. The previous chain of sequential .replace() calls re-scanned already-substituted values, so a singleUserName containing a literal `{{actions}}` would land in `subtitle` and then get expanded by the later `.replace(/{{actions}}/g, …)` — letting any pod owner inject other template fragments via their name. Switch to a single pass: build a values map, then run one .replace(/{{(\w+)}}/g, (m, k) => values[k]) over the original template. Each token is matched against `tpl` exactly once and substituted from the map; substituted text is not re-scanned. Also keeps the `$&` immunity (still using the function form of replace). Regression test asserts a singleUserName of "evil{{actions}}name" appears verbatim in the output and does not get the actions block spliced in. 813/813 tests pass. --- src/ui/server-root.js | 36 ++++++++++++++++++++++-------------- test/server-root.test.js | 22 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/ui/server-root.js b/src/ui/server-root.js index 1ae6f99..d406163 100644 --- a/src/ui/server-root.js +++ b/src/ui/server-root.js @@ -79,20 +79,28 @@ export function renderServerRoot(ctx = {}) { ? '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.'; - // Replacements use the function form, not the string form: a string - // replacement interprets `$&`, `$1`, etc. as substitution patterns, - // which would corrupt any interpolated value containing a `$` (e.g. - // a single-user name). The function form skips that interpretation - // entirely. See #433. - 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); + // 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 = '') { diff --git a/test/server-root.test.js b/test/server-root.test.js index 39f38cf..1ae701c 100644 --- a/test/server-root.test.js +++ b/test/server-root.test.js @@ -148,6 +148,28 @@ 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