docs/superpowers/plans/2026-05-18-admin-settings-resolved-runtime.md
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make /admin/settings show resolved env-var values alongside the raw ${VAR:default} template, so operators see what Etherpad is actually running with.
Architecture: Server emits a new resolved field on the existing 'settings' socket event — the in-memory settings module passed through a secrets redactor. Client stores it alongside the raw file blob and uses it to render a → value chip inside the existing EnvPill widget. saveSettings round-trip is unchanged so template literals stay intact on disk.
Tech Stack: TypeScript, Node 22+, socket.io, React 18, jsonc-parser, mocha (backend tests), node:test via tsx (admin tests), Playwright (e2e).
Working tree: /home/jose/etherpad/etherpad-issue-7803
Branch: 7803-admin-settings-resolved-runtime
Spec: docs/superpowers/specs/2026-05-18-admin-settings-resolved-runtime-design.md
Issue: ether/etherpad#7803
New files:
src/node/utils/AdminSettingsRedact.ts — pure redactorsrc/tests/backend/specs/admin/adminSettingsRedact.ts — mocha unit testssrc/tests/backend/specs/admin/adminSettingsResolved.ts — mocha socket integration testadmin/src/utils/resolveByPath.ts — JSON path walkeradmin/src/utils/__tests__/resolveByPath.test.ts — node:test unit testsadmin/src/components/settings/widgets/__tests__/EnvPill.test.tsx — node:test component testssrc/tests/frontend/specs/admin-settings-resolved.spec.ts — Playwright e2eModified files:
src/node/hooks/express/adminsettings.ts — emit resolved fieldadmin/src/store/store.ts — store + selector for resolvedadmin/src/components/settings/widgets/EnvPill.tsx — add resolvedValue prop + chipadmin/src/components/settings/JsoncNode.tsx — pass resolved value to EnvPillsrc/locales/en.json — new i18n keysFiles:
Create: src/tests/backend/specs/admin/adminSettingsRedact.ts
Step 1: Write the failing test file
'use strict';
import {strict as assert} from 'assert';
import {redactSettings} from '../../../../node/utils/AdminSettingsRedact';
describe('AdminSettingsRedact', function () {
it('returns a deep clone, never mutates input', function () {
const input = {dbSettings: {password: 'secret'}};
const out = redactSettings(input) as any;
assert.equal(input.dbSettings.password, 'secret');
assert.equal(out.dbSettings.password, '[REDACTED]');
assert.notEqual(out.dbSettings, input.dbSettings);
});
it('redacts users.*.password and users.*.passwordHash', function () {
const out = redactSettings({
users: {
admin: {password: 'p1', is_admin: true},
bob: {passwordHash: 'bcrypt$...'},
},
}) as any;
assert.equal(out.users.admin.password, '[REDACTED]');
assert.equal(out.users.admin.is_admin, true); // sibling preserved
assert.equal(out.users.bob.passwordHash, '[REDACTED]');
});
it('redacts users.*.hash (older spelling)', function () {
const out = redactSettings({users: {alice: {hash: 'old$...'}}}) as any;
assert.equal(out.users.alice.hash, '[REDACTED]');
});
it('redacts dbSettings.password and dbSettings.user', function () {
const out = redactSettings({
dbSettings: {host: 'localhost', user: 'etherpad', password: 'secret', filename: '/data/etherpad.db'},
}) as any;
assert.equal(out.dbSettings.password, '[REDACTED]');
assert.equal(out.dbSettings.user, '[REDACTED]');
assert.equal(out.dbSettings.host, 'localhost');
assert.equal(out.dbSettings.filename, '/data/etherpad.db'); // NOT redacted
});
it('redacts sso.clients[*].client_secret and .secret', function () {
const out = redactSettings({
sso: {
clients: [
{client_id: 'app1', client_secret: 'shhh'},
{client_id: 'app2', secret: 'older-style'},
],
},
}) as any;
assert.equal(out.sso.clients[0].client_secret, '[REDACTED]');
assert.equal(out.sso.clients[0].client_id, 'app1');
assert.equal(out.sso.clients[1].secret, '[REDACTED]');
assert.equal(out.sso.clients[1].client_id, 'app2');
});
it('redacts top-level sessionKey', function () {
const out = redactSettings({sessionKey: 'sign-me'}) as any;
assert.equal(out.sessionKey, '[REDACTED]');
});
it('emits [REDACTED] sentinel for null/unset secret values', function () {
const out = redactSettings({dbSettings: {password: null}}) as any;
assert.equal(out.dbSettings.password, '[REDACTED]');
});
it('drops functions and other non-serialisable values', function () {
const out = redactSettings({
port: 9001,
reloadSettings: () => {},
dbSettings: {password: 'x'},
}) as any;
assert.equal(out.port, 9001);
assert.equal(out.reloadSettings, undefined);
assert.equal(out.dbSettings.password, '[REDACTED]');
});
it('leaves non-sensitive keys untouched', function () {
const input = {
port: 9001,
ip: '0.0.0.0',
loglevel: 'INFO',
trustProxy: false,
defaultPadText: 'Welcome!',
};
const out = redactSettings(input) as any;
assert.deepEqual(out, input);
});
it('handles deeply nested arrays of objects', function () {
const out = redactSettings({
sso: {clients: [{nested: {client_secret: 'nope'}}]},
}) as any;
// client_secret only matches at sso.clients[*].client_secret, not nested deeper.
assert.equal(out.sso.clients[0].nested.client_secret, 'nope');
});
});
cd /home/jose/etherpad/etherpad-issue-7803
pnpm install
cd src && pnpm exec mocha --require ts-node/register tests/backend/specs/admin/adminSettingsRedact.ts
Expected: FAIL with Cannot find module ... AdminSettingsRedact.
Note for executor: If
pnpm exec mochais not the project's way to invoke mocha, mirror whatever pattern the sibling tests use — checkpackage.jsonscripttest:backendand other files insrc/tests/backend/specs/admin/to find the canonical invocation.
Files:
Create: src/node/utils/AdminSettingsRedact.ts
Step 1: Implement the redactor
// src/node/utils/AdminSettingsRedact.ts
//
// Produce a clone of the in-memory settings object suitable for emitting
// to the admin SPA. Secrets are replaced with the sentinel "[REDACTED]"
// so the runtime values surface in the UI without leaking credentials.
const SENTINEL = '[REDACTED]';
// Path patterns. '*' matches any object key OR array index.
// A leaf matches if its full path equals one of these patterns.
const REDACT_PATHS: ReadonlyArray<ReadonlyArray<string>> = [
['users', '*', 'password'],
['users', '*', 'passwordHash'],
['users', '*', 'hash'],
['dbSettings', 'password'],
['dbSettings', 'user'],
['sso', 'clients', '*', 'client_secret'],
['sso', 'clients', '*', 'secret'],
['sessionKey'],
];
const pathMatches = (path: ReadonlyArray<string>): boolean => {
for (const pattern of REDACT_PATHS) {
if (pattern.length !== path.length) continue;
let ok = true;
for (let i = 0; i < pattern.length; i++) {
if (pattern[i] !== '*' && pattern[i] !== path[i]) { ok = false; break; }
}
if (ok) return true;
}
return false;
};
const walk = (value: unknown, path: string[]): unknown => {
if (pathMatches(path)) return SENTINEL;
if (value === null || value === undefined) return value;
if (typeof value === 'function') return undefined;
if (Array.isArray(value)) {
return value.map((v, i) => walk(v, [...path, String(i)]));
}
if (typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
const child = walk(v, [...path, k]);
if (child !== undefined) out[k] = child;
}
return out;
}
// primitives
return value;
};
export const redactSettings = (settings: unknown): unknown => walk(settings, []);
cd /home/jose/etherpad/etherpad-issue-7803/src && pnpm exec mocha --require ts-node/register tests/backend/specs/admin/adminSettingsRedact.ts
Expected: all 9 tests PASS.
cd /home/jose/etherpad/etherpad-issue-7803
git add src/node/utils/AdminSettingsRedact.ts src/tests/backend/specs/admin/adminSettingsRedact.ts
git commit -m "$(cat <<'EOF'
feat(admin): add redactor for resolved settings payload (#7803)
Pure helper that clones the live settings module and replaces known
sensitive paths (users.*.password, dbSettings.password,
sso.clients[*].client_secret, sessionKey, …) with [REDACTED] sentinel.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
Files:
Modify: src/node/hooks/express/adminsettings.ts:48-70
Step 1: Add the import and emit resolved
In src/node/hooks/express/adminsettings.ts, at the import block (line 10), add:
import {redactSettings} from '../../utils/AdminSettingsRedact';
Replace the socket.on('load') handler (lines 54-70) with:
socket.on('load', async (query: string): Promise<any> => {
let data;
try {
data = await fsp.readFile(settings.settingsFilename, 'utf8');
} catch (err) {
return logger.error(`Error loading settings: ${err}`);
}
const flags = {
gdprAuthorErasure: !!(settings.gdprAuthorErasure &&
settings.gdprAuthorErasure.enabled),
};
if (settings.showSettingsInAdminPage === false) {
socket.emit('settings', {results: 'NOT_ALLOWED', flags});
} else {
const resolved = redactSettings(settings);
socket.emit('settings', {results: data, resolved, flags});
}
});
cd /home/jose/etherpad/etherpad-issue-7803/src && pnpm exec tsc --noEmit
Expected: no errors related to adminsettings.ts.
Files:
Create: src/tests/backend/specs/admin/adminSettingsResolved.ts
Step 1: Write the integration test
Model after src/tests/backend/specs/admin/anonymizeAuthorSocket.ts for the admin socket setup boilerplate.
'use strict';
import {strict as assert} from 'assert';
import setCookieParser from 'set-cookie-parser';
const io = require('socket.io-client');
const common = require('../../common');
const settings = require('../../../../node/utils/Settings');
const adminSocket = async () => {
settings.users = settings.users || {};
settings.users['test-admin'] = {password: 'test-admin-password', is_admin: true};
const saved = settings.requireAuthentication;
settings.requireAuthentication = true;
let res: any;
try {
res = await (common.agent as any)
.get('/admin/')
.auth('test-admin', 'test-admin-password');
} finally {
settings.requireAuthentication = saved;
}
const resCookies = setCookieParser.parse(res, {map: true});
const reqCookieHdr = Object.entries(resCookies)
.map(([name, cookie]: [string, any]) =>
`${name}=${encodeURIComponent(cookie.value)}`)
.join('; ');
const socket = io(`${common.baseUrl}/settings`, {
forceNew: true,
query: {cookie: reqCookieHdr},
});
await new Promise<void>((res, rej) => {
const onErr = (err: any) => { socket.off('connect', onErr); rej(err); };
const onConn = () => { socket.off('connect_error', onErr); res(); };
socket.once('connect', onConn);
socket.once('connect_error', onErr);
});
return socket;
};
const ask = (socket: any, evt: string, payload: any, replyEvt: string) =>
new Promise<any>((res) => {
socket.once(replyEvt, res);
socket.emit(evt, payload);
});
describe('/admin/settings socket load emits resolved', function () {
this.timeout(60000);
let socket: any;
let savedPwd: any;
let savedTrust: any;
let savedSessionKey: any;
before(async function () {
// Mutate the in-memory settings module so we can assert that what's
// emitted reflects the runtime, not the file on disk.
savedPwd = settings.dbSettings?.password;
savedTrust = settings.trustProxy;
savedSessionKey = settings.sessionKey;
settings.dbSettings = settings.dbSettings || {};
settings.dbSettings.password = 'live-password';
settings.trustProxy = true;
settings.sessionKey = 'live-key';
socket = await adminSocket();
});
after(async function () {
if (socket) socket.disconnect();
if (savedPwd === undefined) delete settings.dbSettings.password;
else settings.dbSettings.password = savedPwd;
settings.trustProxy = savedTrust;
settings.sessionKey = savedSessionKey;
});
it('emits {results, resolved, flags}', async function () {
const reply: any = await ask(socket, 'load', null, 'settings');
assert.ok(reply, 'reply present');
assert.equal(typeof reply.results, 'string', 'raw file string');
assert.equal(typeof reply.resolved, 'object', 'resolved object');
assert.ok(reply.flags, 'flags present');
});
it('resolved reflects live mutated values, not the file on disk', async function () {
const reply: any = await ask(socket, 'load', null, 'settings');
assert.equal(reply.resolved.trustProxy, true,
'resolved should show the in-memory trustProxy');
});
it('resolved redacts secrets', async function () {
const reply: any = await ask(socket, 'load', null, 'settings');
assert.equal(reply.resolved.dbSettings.password, '[REDACTED]');
assert.equal(reply.resolved.sessionKey, '[REDACTED]');
});
it('resolved is omitted when showSettingsInAdminPage is false', async function () {
const savedShow = settings.showSettingsInAdminPage;
settings.showSettingsInAdminPage = false;
try {
const reply: any = await ask(socket, 'load', null, 'settings');
assert.equal(reply.results, 'NOT_ALLOWED');
assert.equal(reply.resolved, undefined);
} finally {
settings.showSettingsInAdminPage = savedShow;
}
});
});
cd /home/jose/etherpad/etherpad-issue-7803/src && pnpm exec mocha --require ts-node/register --recursive tests/backend/specs/admin/
Expected: all admin specs PASS, including the 4 new ones.
If the existing admin specs use a different mocha invocation: mirror that. Check
src/package.jsonscripts.test:backendfor the canonical command.
cd /home/jose/etherpad/etherpad-issue-7803
git add src/node/hooks/express/adminsettings.ts src/tests/backend/specs/admin/adminSettingsResolved.ts
git commit -m "$(cat <<'EOF'
feat(admin): emit redacted runtime settings on /settings socket load (#7803)
Existing 'results' raw-file blob is unchanged so the textarea editor
and saveSettings round-trip continue to preserve \${VAR:default}
literals on disk. New 'resolved' field carries the in-memory settings
module run through the redactor — admin SPA can use it to show actual
runtime values next to env-var placeholders.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
Files:
Create: admin/src/utils/resolveByPath.ts
Create: admin/src/utils/__tests__/resolveByPath.test.ts
Step 1: Write the failing test
// admin/src/utils/__tests__/resolveByPath.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveByPath } from '../resolveByPath.ts';
test('returns undefined for null/undefined root', () => {
assert.equal(resolveByPath(null, ['a']), undefined);
assert.equal(resolveByPath(undefined, ['a']), undefined);
});
test('walks nested object keys', () => {
assert.equal(resolveByPath({a: {b: {c: 42}}}, ['a', 'b', 'c']), 42);
});
test('walks arrays with numeric indices', () => {
assert.equal(resolveByPath({xs: [10, 20, 30]}, ['xs', 1]), 20);
});
test('walks mixed objects and arrays', () => {
assert.equal(
resolveByPath({sso: {clients: [{id: 'A'}, {id: 'B'}]}}, ['sso', 'clients', 1, 'id']),
'B',
);
});
test('returns undefined for missing keys', () => {
assert.equal(resolveByPath({a: 1}, ['b']), undefined);
assert.equal(resolveByPath({a: {b: 1}}, ['a', 'c']), undefined);
});
test('returns undefined when traversing into a primitive', () => {
assert.equal(resolveByPath({a: 1}, ['a', 'b']), undefined);
});
test('returns the root when path is empty', () => {
const obj = {a: 1};
assert.equal(resolveByPath(obj, []), obj);
});
test('handles string-form numeric indices for arrays', () => {
// jsonc-parser sometimes emits string indices.
assert.equal(resolveByPath({xs: [10, 20]}, ['xs', '1']), 20);
});
cd /home/jose/etherpad/etherpad-issue-7803/admin && pnpm install
pnpm exec tsx --test src/utils/__tests__/resolveByPath.test.ts
Expected: FAIL (Cannot find module './resolveByPath').
// admin/src/utils/resolveByPath.ts
import type { JSONPath } from 'jsonc-parser';
export const resolveByPath = (obj: unknown, path: JSONPath): unknown => {
let cur: unknown = obj;
for (const seg of path) {
if (cur === null || cur === undefined) return undefined;
if (typeof cur !== 'object') return undefined;
if (Array.isArray(cur)) {
const i = typeof seg === 'number' ? seg : Number(seg);
if (!Number.isInteger(i)) return undefined;
cur = cur[i];
} else {
cur = (cur as Record<string, unknown>)[String(seg)];
}
}
return cur;
};
cd /home/jose/etherpad/etherpad-issue-7803/admin && pnpm exec tsx --test src/utils/__tests__/resolveByPath.test.ts
Expected: all 8 tests PASS.
cd /home/jose/etherpad/etherpad-issue-7803
git add admin/src/utils/resolveByPath.ts admin/src/utils/__tests__/resolveByPath.test.ts
git commit -m "$(cat <<'EOF'
feat(admin): add resolveByPath JSONPath walker (#7803)
Pure helper for indexing into a plain-object resolved-settings payload
using a jsonc-parser JSONPath. Returns undefined on miss so callers can
fall back when an old server omitted the resolved field.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
resolvedFiles:
Modify: admin/src/store/store.ts
Step 1: Read the current store
sed -n '1,80p' /home/jose/etherpad/etherpad-issue-7803/admin/src/store/store.ts
Identify (1) the settings field declaration, (2) the setSettings setter, (3) the socket listener that fires setSettings(results) on the 'settings' event.
resolved field, setter, selector hookIn admin/src/store/store.ts:
Add to the state shape (alongside settings):
resolved: unknown | null;
setResolved: (r: unknown | null) => void;
Add to the store implementation initial state:
resolved: null,
setResolved: (resolved) => set({resolved}),
In the socket 'settings' listener (where setSettings(payload.results) lives), add:
useStore.getState().setResolved(payload.resolved ?? null);
At the bottom of the file (or wherever existing selector hooks live), add:
import type { JSONPath } from 'jsonc-parser';
import { resolveByPath } from '../utils/resolveByPath';
export const useResolvedAt = (path: JSONPath): unknown =>
useStore(s => resolveByPath(s.resolved, path));
Note for executor: If the store file already imports
JSONPathorresolveByPath, dedupe. If the file's pattern groups selectors elsewhere, follow that. Don't unilaterally refactor the file.
cd /home/jose/etherpad/etherpad-issue-7803/admin && pnpm exec tsc --noEmit
Expected: no errors.
Files:
Modify: src/locales/en.json
Step 1: Add the new keys
Open src/locales/en.json and find the existing admin_settings.env_pill.* keys (around line 139-141). Add immediately after them:
"admin_settings.env_pill.runtime_label": "active value",
"admin_settings.env_pill.runtime_tooltip": "Etherpad is currently using this value, resolved from {{variable}} or its default.",
"admin_settings.env_pill.redacted_tooltip": "Etherpad is using a value for {{variable}}, but it is hidden because it is a secret.",
node -e "JSON.parse(require('fs').readFileSync('/home/jose/etherpad/etherpad-issue-7803/src/locales/en.json'))"
Expected: no output (no syntax error).
Files:
Create: admin/src/components/settings/widgets/__tests__/EnvPill.test.tsx
Step 1: Check what testing-library setup admin uses
grep -l "render\|@testing-library" /home/jose/etherpad/etherpad-issue-7803/admin/src/**/*.test.* 2>/dev/null
cat /home/jose/etherpad/etherpad-issue-7803/admin/package.json | grep -A 30 '"devDependencies"'
Decision point: If
@testing-library/reactis already a devDependency, write a render-based test (preferred). If not, fall back to a plain function-call test that snapshot-asserts the React tree shape. The minimal version below usesreact-dom/server.renderToStaticMarkupwhich needs no extra deps.
// admin/src/components/settings/widgets/__tests__/EnvPill.test.tsx
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nextProvider } from 'react-i18next';
import i18next from 'i18next';
import { EnvPill } from '../EnvPill';
i18next.init({
lng: 'en',
resources: {
en: {
translation: {
'admin_settings.env_pill.tooltip': 'env {{variable}}',
'admin_settings.env_pill.default_label': 'default',
'admin_settings.env_pill.input_aria': 'aria {{variable}}',
'admin_settings.env_pill.runtime_label': 'active',
'admin_settings.env_pill.runtime_tooltip': 'using {{variable}}',
'admin_settings.env_pill.redacted_tooltip': 'hidden {{variable}}',
},
},
},
interpolation: { escapeValue: false },
});
const wrap = (el: React.ReactElement) =>
renderToStaticMarkup(
React.createElement(I18nextProvider, { i18n: i18next }, el),
);
test('omits runtime chip when resolvedValue is undefined', () => {
const html = wrap(React.createElement(EnvPill, {
placeholder: { variable: 'DB_TYPE', defaultValue: 'dirty' },
path: ['dbType'],
onChange: () => {},
}));
assert.ok(!html.includes('settings-widget-env-runtime'),
'runtime chip should be absent');
});
test('renders runtime chip with resolved value', () => {
const html = wrap(React.createElement(EnvPill, {
placeholder: { variable: 'DB_TYPE', defaultValue: 'dirty' },
path: ['dbType'],
onChange: () => {},
resolvedValue: 'sqlite',
} as any));
assert.ok(html.includes('settings-widget-env-runtime'),
'runtime chip class should appear');
assert.ok(html.includes('sqlite'),
'resolved value text should appear');
});
test('renders redacted chip when resolvedValue is [REDACTED]', () => {
const html = wrap(React.createElement(EnvPill, {
placeholder: { variable: 'DB_PASS', defaultValue: '' },
path: ['dbSettings', 'password'],
onChange: () => {},
resolvedValue: '[REDACTED]',
} as any));
assert.ok(html.includes('settings-widget-env-runtime-redacted'),
'redacted chip class should appear');
assert.ok(!html.includes('[REDACTED]'),
'literal sentinel must not be displayed to the user');
});
test('coerces non-string resolved values to display strings', () => {
const html = wrap(React.createElement(EnvPill, {
placeholder: { variable: 'TRUST_PROXY', defaultValue: 'false' },
path: ['trustProxy'],
onChange: () => {},
resolvedValue: true,
} as any));
assert.ok(html.includes('true'));
});
test('renders null resolved value as the string null', () => {
const html = wrap(React.createElement(EnvPill, {
placeholder: { variable: 'IP', defaultValue: '' },
path: ['ip'],
onChange: () => {},
resolvedValue: null,
} as any));
// null is meaningful (env unset, no default) — show "null" rather than swallow
assert.ok(html.includes('null'));
});
cd /home/jose/etherpad/etherpad-issue-7803/admin && pnpm exec tsx --test src/components/settings/widgets/__tests__/EnvPill.test.tsx
Expected: all 5 tests FAIL on assertion (because EnvPill doesn't accept resolvedValue yet).
Files:
Modify: admin/src/components/settings/widgets/EnvPill.tsx
Step 1: Add the resolvedValue prop and chip rendering
Replace the entire file with:
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { JSONPath } from 'jsonc-parser';
import type { EnvPlaceholder } from '../envPill';
const REDACTED = '[REDACTED]';
type Props = {
placeholder: EnvPlaceholder;
path: JSONPath;
onChange: (newDefault: string) => void;
resolvedValue?: unknown;
};
const sanitize = (s: string) => s.replace(/[}]/g, '');
const formatDisplay = (v: unknown): string => {
if (v === null) return 'null';
if (typeof v === 'string') return v;
return String(v);
};
export const EnvPill = ({ placeholder, path, onChange, resolvedValue }: Props) => {
const { t } = useTranslation();
const initial = placeholder.defaultValue ?? '';
const [draft, setDraft] = useState(initial);
const focused = useRef(false);
useEffect(() => {
if (!focused.current) setDraft(initial);
}, [initial]);
const id = `field-${path.join('.')}`;
const testid = `env-${path.join('.')}`;
// Distinguish three runtime states:
// undefined → server didn't send resolved (old server, or path not present)
// '[REDACTED]' → secret hidden
// anything else → live runtime value
const hasResolved = resolvedValue !== undefined;
const isRedacted = resolvedValue === REDACTED;
return (
<span
className="settings-widget settings-widget-env"
title={t('admin_settings.env_pill.tooltip', { variable: placeholder.variable })}
>
<span className="settings-widget-env-icon" aria-hidden>ⓔ</span>
<span className="settings-widget-env-name">{placeholder.variable}</span>
<span className="settings-widget-env-default-label" aria-hidden>
{t('admin_settings.env_pill.default_label')}
</span>
<input
id={id}
data-testid={testid}
className="settings-widget-env-default-input"
type="text"
value={draft}
spellCheck={false}
aria-label={t('admin_settings.env_pill.input_aria', { variable: placeholder.variable })}
onFocus={() => { focused.current = true; }}
onBlur={() => { focused.current = false; }}
onChange={e => {
const v = sanitize(e.target.value);
setDraft(v);
onChange(v);
}}
/>
{hasResolved && !isRedacted && (
<span
className="settings-widget-env-runtime"
data-testid={`env-runtime-${path.join('.')}`}
title={t('admin_settings.env_pill.runtime_tooltip', { variable: placeholder.variable })}
>
<span className="settings-widget-env-runtime-arrow" aria-hidden>→</span>
<span className="settings-widget-env-runtime-label" aria-hidden>
{t('admin_settings.env_pill.runtime_label')}
</span>
<span className="settings-widget-env-runtime-value">
{formatDisplay(resolvedValue)}
</span>
</span>
)}
{isRedacted && (
<span
className="settings-widget-env-runtime settings-widget-env-runtime-redacted"
data-testid={`env-runtime-redacted-${path.join('.')}`}
title={t('admin_settings.env_pill.redacted_tooltip', { variable: placeholder.variable })}
aria-label={t('admin_settings.env_pill.redacted_tooltip', { variable: placeholder.variable })}
>
<span aria-hidden>→ ••••••</span>
</span>
)}
</span>
);
};
cd /home/jose/etherpad/etherpad-issue-7803/admin && pnpm exec tsx --test src/components/settings/widgets/__tests__/EnvPill.test.tsx
Expected: all 5 tests PASS.
cd /home/jose/etherpad/etherpad-issue-7803
git add admin/src/utils/resolveByPath.ts admin/src/utils/__tests__/resolveByPath.test.ts \
admin/src/store/store.ts \
admin/src/components/settings/widgets/EnvPill.tsx \
admin/src/components/settings/widgets/__tests__/EnvPill.test.tsx \
src/locales/en.json
git commit -m "$(cat <<'EOF'
feat(admin): show resolved runtime value chip on EnvPill (#7803)
Store now caches the resolved field from the /settings socket payload.
useResolvedAt(path) walks it via the existing jsonc-parser JSONPath.
EnvPill optionally renders a "→ active value" chip when a resolved
value is available, or a redacted indicator when the server returned
the [REDACTED] sentinel. Old-server fallback (undefined) keeps current
behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
Files:
Modify: admin/src/components/settings/JsoncNode.tsx
Step 1: Plumb resolvedValue through the leaf render
In admin/src/components/settings/JsoncNode.tsx:
Add an import at the top:
import { useResolvedAt } from '../../store/store';
Modify the leaf render so the EnvPill branch receives the resolved value. Either:
Option A (preferred): Lift the useResolvedAt call up into the function-component body of JsoncNode, then thread it into renderLeaf. Since renderLeaf is currently a free function (not a hook context), the cleanest change is to extract the env-placeholder branch out of renderLeaf and inline it in the component:
// Inside JsoncNode, before the existing `// ---- Leaf row ----` comment:
const isEnvPlaceholder =
node.type === 'string' &&
matchEnvPlaceholder(text.slice(node.offset, node.offset + node.length)) !== null;
const resolvedForPath = useResolvedAt(path);
const renderLeafLocal = () => {
if (node.type === 'string') {
const raw = text.slice(node.offset, node.offset + node.length);
const env = matchEnvPlaceholder(raw);
if (env) {
return (
<EnvPill
placeholder={env}
path={path}
onChange={(d) => onEdit(path, `\${${env.variable}:${d}}`)}
resolvedValue={isEnvPlaceholder ? resolvedForPath : undefined}
/>
);
}
return (
<StringInput value={String(node.value)} path={path} onChange={v => onEdit(path, v)} />
);
}
// delegate the rest of the leaf cases to the existing renderLeaf
return renderLeaf(node, path, text, onEdit);
};
Then change the existing leaf row return to call renderLeafLocal() instead of renderLeaf(...).
Why this shape:
useResolvedAtis a hook and can only be called inside a component, not insiderenderLeaf(a free function). The branch above keeps the rest ofrenderLeafuntouched so the diff stays small.
cd /home/jose/etherpad/etherpad-issue-7803/admin && pnpm exec tsc --noEmit && pnpm exec eslint src/components/settings/JsoncNode.tsx
Expected: no errors, no warnings.
cd /home/jose/etherpad/etherpad-issue-7803
git add admin/src/components/settings/JsoncNode.tsx
git commit -m "$(cat <<'EOF'
feat(admin): pass resolved runtime value into EnvPill (#7803)
JsoncNode now looks up the resolved value at the current JSONPath via
useResolvedAt and threads it into EnvPill. Operators see the actual
runtime value of every env-substituted setting alongside the template.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
Files:
Modify: whichever stylesheet currently styles .settings-widget-env-* (grep to find it)
Step 1: Locate the existing env-pill styles
cd /home/jose/etherpad/etherpad-issue-7803/admin && grep -rn "settings-widget-env" src/ --include="*.css" --include="*.scss"
Add adjacent to the existing env-pill rules:
.settings-widget-env-runtime {
display: inline-flex;
align-items: center;
gap: 0.25em;
margin-left: 0.5em;
padding: 0.1em 0.5em;
border-radius: 0.5em;
background: rgba(0, 128, 0, 0.08);
color: rgba(0, 80, 0, 0.85);
font-size: 0.85em;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.settings-widget-env-runtime-redacted {
background: rgba(128, 128, 128, 0.12);
color: rgba(80, 80, 80, 0.85);
}
.settings-widget-env-runtime-arrow {
opacity: 0.6;
}
.settings-widget-env-runtime-label {
opacity: 0.65;
font-style: italic;
}
Why minimal: matching the existing env-pill visual weight, no animation, no theme variables. If the file uses CSS custom properties for colours, swap the rgba() values for the equivalent tokens to keep consistency.
cd /home/jose/etherpad/etherpad-issue-7803
git add admin/src/ # or the specific css file path from grep
git commit -m "$(cat <<'EOF'
style(admin): runtime-value chip styles for EnvPill (#7803)
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
Files:
Create: src/tests/frontend/specs/admin-settings-resolved.spec.ts
Step 1: Check the existing Playwright test setup
ls /home/jose/etherpad/etherpad-issue-7803/src/tests/frontend/specs/ | head
cat /home/jose/etherpad/etherpad-issue-7803/playwright.config.ts 2>/dev/null || \
cat /home/jose/etherpad/etherpad-issue-7803/src/playwright.config.ts
Identify (a) admin login pattern, (b) port (must be 9003 per [[feedback_test_port_9003]]), (c) how to set env vars on the server-under-test process.
// src/tests/frontend/specs/admin-settings-resolved.spec.ts
//
// Repro for #7803. With DB_TYPE=sqlite set in the server's env, the
// admin settings page must show the resolved value next to the env
// placeholder, not just the template default.
import { test, expect } from '@playwright/test';
test.describe('admin /settings resolved runtime values', () => {
test('env pill shows resolved value chip', async ({ page }) => {
// Note: this test depends on the server-under-test having been
// booted with DB_TYPE set to a value distinct from the template
// default. The Playwright config (or a per-test setup) sets this.
await page.goto('http://localhost:9003/admin/login');
await page.fill('input[name="username"]', 'admin');
await page.fill('input[name="password"]', 'changeme1');
await page.click('button[type="submit"]');
await page.waitForURL('**/admin/**');
await page.goto('http://localhost:9003/admin/settings');
// Switch to form view if not already.
const formToggle = page.locator('[data-testid="settings-form-view"]').first();
await expect(formToggle).toBeVisible({ timeout: 10000 });
// The dbType row's env pill should expose a runtime chip whose
// value matches the resolved DB_TYPE env var.
const runtime = page.locator('[data-testid^="env-runtime-dbType"]');
await expect(runtime).toBeVisible();
await expect(runtime).toContainText(process.env.DB_TYPE || 'sqlite');
});
test('secret values render as redacted chip', async ({ page }) => {
// Requires settings.json fixture that uses ${DB_PASS:secret} for
// dbSettings.password. If the live test settings don't include
// that placeholder we skip rather than misleadingly pass.
await page.goto('http://localhost:9003/admin/settings');
const redacted = page.locator('[data-testid^="env-runtime-redacted-dbSettings.password"]');
if (await redacted.count() === 0) test.skip();
await expect(redacted).toBeVisible();
await expect(redacted).not.toContainText('[REDACTED]'); // sentinel not exposed
});
});
Start the server on port 9003 with DB_TYPE=sqlite set, then run Playwright. If the project provides a pnpm test:e2e script that takes a port flag, use that; otherwise:
cd /home/jose/etherpad/etherpad-issue-7803
DB_TYPE=sqlite PORT=9003 pnpm run dev &
sleep 8
pnpm exec playwright test src/tests/frontend/specs/admin-settings-resolved.spec.ts
Expected: first test PASS, second test PASS-or-SKIP depending on test settings fixture.
If the e2e harness has its own way of declaring per-test env: prefer that over the shell-prefix above. Check
playwright.config.tsforwebServer.env.
cd /home/jose/etherpad/etherpad-issue-7803
git add src/tests/frontend/specs/admin-settings-resolved.spec.ts
git commit -m "$(cat <<'EOF'
test(admin): e2e for resolved runtime value chip (#7803)
Boots a real browser against an Etherpad with DB_TYPE=sqlite set and
asserts the env pill shows '→ sqlite' rather than the template default.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
cd /home/jose/etherpad/etherpad-issue-7803/src && pnpm exec mocha --require ts-node/register --recursive tests/backend/specs/admin/
Expected: all PASS.
cd /home/jose/etherpad/etherpad-issue-7803/admin && pnpm test
Expected: all PASS.
cd /home/jose/etherpad/etherpad-issue-7803/admin && pnpm exec tsc --noEmit && pnpm exec eslint .
cd /home/jose/etherpad/etherpad-issue-7803/src && pnpm exec tsc --noEmit
Expected: no errors.
cd /home/jose/etherpad/etherpad-issue-7803
git push -u origin 7803-admin-settings-resolved-runtime
gh pr create --base develop \
--title "fix(admin): show resolved runtime values on /admin/settings (#7803)" \
--body "$(cat <<'EOF'
## Summary
- Server emits an additional \`resolved\` field on the \`/settings\` socket \`load\` event: the in-memory settings module run through a secrets redactor. Existing \`results\` raw-file blob is unchanged so the textarea editor and \`saveSettings\` round-trip keep \`\${VAR:default}\` literals intact on disk.
- Admin SPA stores the resolved object alongside the raw text. EnvPill renders a \`→ active value\` chip when a resolved value is available, or \`→ ••••••\` when the server returned the \`[REDACTED]\` sentinel.
- Fixes #7803 — operators running Etherpad under Docker / Kubernetes / Home Assistant can now verify the actual runtime config from the admin UI instead of having to grep the boot log.
## Test plan
- [ ] Backend mocha admin specs pass, including new redactor unit tests and socket integration test.
- [ ] Admin frontend \`node:test\` suite passes, including new EnvPill + resolveByPath tests.
- [ ] Playwright e2e: with \`DB_TYPE=sqlite\` in the env, \`/admin/settings\` shows \`→ sqlite\` next to the dbType env pill.
- [ ] Manual: \`docker run -e DB_TYPE=sqlite -e DB_FILENAME=/data/etherpad.db etherpad/etherpad\`, open /admin/settings, verify dbType pill shows the resolved value and any secret-shaped setting shows the redacted indicator.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
Per [[feedback_check_ci_after_pr]]: wait ~20s, then:
sleep 20 && gh pr checks "$(gh pr view --json number -q .number)"
Address any failures immediately before moving on. Per [[feedback_qodo_pr_feedback]]: fetch Qodo's review comments and fix or reply.
resolveByPath signature is consistent across Task 5/6/10. redactSettings signature is consistent across Task 1/2/3/4. EnvPill.resolvedValue prop is consistent across Task 8/9/10.