diff --git a/admin/package.json b/admin/package.json
index dc6a878774e..f8cc8bd52eb 100644
--- a/admin/package.json
+++ b/admin/package.json
@@ -11,7 +11,7 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"build-copy": "pnpm gen:api && tsc && vite build --outDir ../src/templates/admin --emptyOutDir",
"preview": "vite preview",
- "test": "pnpm gen:api && tsx --test 'src/**/__tests__/*.test.ts'"
+ "test": "pnpm gen:api && tsx --test 'src/**/__tests__/*.test.ts' 'src/**/__tests__/*.test.tsx'"
},
"dependencies": {
"@radix-ui/react-switch": "^1.2.6",
diff --git a/admin/src/App.css b/admin/src/App.css
index d7a364c5f72..a064a56f9aa 100644
--- a/admin/src/App.css
+++ b/admin/src/App.css
@@ -233,6 +233,32 @@ textarea.settings:focus {
outline: 2px solid var(--accent, #2b8a3e);
outline-offset: 1px;
}
+.settings-widget-env-runtime {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ margin-left: 4px;
+ padding: 1px 8px;
+ border-radius: 10px;
+ background: #e6f4ea;
+ color: #1e5631;
+ font-family: "Fira Code", monospace;
+ font-size: 12px;
+}
+.settings-widget-env-runtime-redacted {
+ background: #ececec;
+ color: #555;
+}
+.settings-widget-env-runtime-arrow {
+ opacity: 0.6;
+}
+.settings-widget-env-runtime-label {
+ font-style: italic;
+ opacity: 0.7;
+}
+.settings-widget-env-runtime-value {
+ font-weight: 600;
+}
/* Radix switch (boolean) */
.settings-widget-boolean {
diff --git a/admin/src/App.tsx b/admin/src/App.tsx
index b7ce261b67e..b182be02fa9 100644
--- a/admin/src/App.tsx
+++ b/admin/src/App.tsx
@@ -56,10 +56,14 @@ export const App = () => {
}
if (settings.results === 'NOT_ALLOWED') {
console.log('Not allowed to view settings.json')
+ useStore.getState().setResolved(null);
return;
}
if (isJSONClean(settings.results)) setSettings(settings.results);
else alert(t('admin_settings.invalid_json'));
+ // The resolved field is optional — old servers won't send it,
+ // and the SPA degrades to today's behaviour when it's null.
+ useStore.getState().setResolved(settings.resolved ?? null);
useStore.getState().setShowLoading(false);
});
diff --git a/admin/src/components/settings/JsoncNode.tsx b/admin/src/components/settings/JsoncNode.tsx
index 52bc41ea2e8..8cf20b1c67c 100644
--- a/admin/src/components/settings/JsoncNode.tsx
+++ b/admin/src/components/settings/JsoncNode.tsx
@@ -9,6 +9,7 @@ import { NumberInput } from './widgets/NumberInput';
import { BooleanToggle } from './widgets/BooleanToggle';
import { NullChip } from './widgets/NullChip';
import { EnvPill } from './widgets/EnvPill';
+import { useResolvedAt } from '../../store/store';
type Props = {
/** The value node (not the property node). */
@@ -36,6 +37,7 @@ const renderLeaf = (
path: JSONPath,
text: string,
onEdit: (path: JSONPath, value: unknown) => void,
+ resolvedValue: unknown,
) => {
if (node.type === 'string') {
const raw = text.slice(node.offset, node.offset + node.length);
@@ -46,6 +48,7 @@ const renderLeaf = (
placeholder={env}
path={path}
onChange={(d) => onEdit(path, `\${${env.variable}:${d}}`)}
+ resolvedValue={resolvedValue}
/>
);
}
@@ -84,6 +87,11 @@ const renderLeaf = (
export const JsoncNode = ({ node, property, text, onEdit, suppressOwnHeader }: Props) => {
const path = getNodePath(node);
const key = propertyKey(property);
+ // useResolvedAt must be called unconditionally for every JsoncNode
+ // render (React hook rules). It's cheap: a shallow zustand selector +
+ // an object-walk that returns undefined when the resolved payload is
+ // absent (old server) — in which case EnvPill simply omits the chip.
+ const resolvedValue = useResolvedAt(path);
const anchor = property ?? node;
const fileComments = extractAdjacentComments(text, anchor.offset, node.offset, node.length);
@@ -163,7 +171,7 @@ export const JsoncNode = ({ node, property, text, onEdit, suppressOwnHeader }: P
{label}
- {renderLeaf(node, path, text, onEdit)}
+ {renderLeaf(node, path, text, onEdit, resolvedValue)}
{help && (
{help}
diff --git a/admin/src/components/settings/widgets/EnvPill.tsx b/admin/src/components/settings/widgets/EnvPill.tsx
index 1440d9d0fa6..9ec97e949c4 100644
--- a/admin/src/components/settings/widgets/EnvPill.tsx
+++ b/admin/src/components/settings/widgets/EnvPill.tsx
@@ -3,22 +3,29 @@ 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, '');
-export const EnvPill = ({ placeholder, path, onChange }: Props) => {
+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);
- // Sync local draft from upstream (server canonicalisation, raw-mode edit)
- // only while the input isn't focused so we don't trample mid-typing.
useEffect(() => {
if (!focused.current) setDraft(initial);
}, [initial]);
@@ -26,6 +33,13 @@ export const EnvPill = ({ placeholder, path, onChange }: Props) => {
const id = `field-${path.join('.')}`;
const testid = `env-${path.join('.')}`;
+ // Three runtime states:
+ // undefined → server didn't send resolved (old server, or path absent)
+ // '[REDACTED]' → secret hidden
+ // anything else → live runtime value
+ const hasResolved = resolvedValue !== undefined;
+ const isRedacted = resolvedValue === REDACTED;
+
return (
{
onChange(v);
}}
/>
+ {hasResolved && !isRedacted && (
+
+ →
+
+ {t('admin_settings.env_pill.runtime_label')}
+
+
+ {formatDisplay(resolvedValue)}
+
+
+ )}
+ {isRedacted && (
+
+ → ••••••
+
+ )}
);
};
diff --git a/admin/src/components/settings/widgets/__tests__/EnvPill.test.tsx b/admin/src/components/settings/widgets/__tests__/EnvPill.test.tsx
new file mode 100644
index 00000000000..1ea2f3b2fc9
--- /dev/null
+++ b/admin/src/components/settings/widgets/__tests__/EnvPill.test.tsx
@@ -0,0 +1,86 @@
+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.tsx';
+
+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 value',
+ '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, got: ${html}`);
+});
+
+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, got: ${html}`);
+ assert.ok(html.includes('sqlite'),
+ `resolved value text should appear, got: ${html}`);
+});
+
+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, got: ${html}`);
+ assert.ok(!html.includes('[REDACTED]'),
+ `literal sentinel must not be displayed to the user, got: ${html}`);
+});
+
+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'), `expected "true" in ${html}`);
+});
+
+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));
+ assert.ok(html.includes('null'), `expected "null" in ${html}`);
+});
diff --git a/admin/src/store/store.ts b/admin/src/store/store.ts
index 5643f9ebebf..2b19bdb2204 100644
--- a/admin/src/store/store.ts
+++ b/admin/src/store/store.ts
@@ -1,8 +1,10 @@
import {create} from "zustand";
import {Socket} from "socket.io-client";
+import type {JSONPath} from "jsonc-parser";
import {PadSearchResult} from "../utils/PadSearch.ts";
import {AuthorSearchResult} from "../utils/AuthorSearch.ts";
import {InstalledPlugin} from "../pages/Plugin.ts";
+import {resolveByPath} from "../utils/resolveByPath.ts";
export type Execution =
| {status: 'idle'}
@@ -66,6 +68,11 @@ type ToastState = {
type StoreState = {
settings: string|undefined,
setSettings: (settings: string) => void,
+ // Resolved runtime values for the /admin/settings page. The server
+ // emits this alongside the raw `settings` string so the SPA can show
+ // env-substituted values; secrets are redacted to "[REDACTED]".
+ resolved: unknown | null,
+ setResolved: (resolved: unknown | null) => void,
settingsSocket: Socket|undefined,
setSettingsSocket: (socket: Socket) => void,
showLoading: boolean,
@@ -92,6 +99,8 @@ type StoreState = {
export const useStore = create()((set) => ({
settings: undefined,
setSettings: (settings: string) => set({settings}),
+ resolved: null,
+ setResolved: (resolved) => set({resolved}),
settingsSocket: undefined,
setSettingsSocket: (socket: Socket) => set({settingsSocket: socket}),
showLoading: false,
@@ -118,3 +127,6 @@ export const useStore = create()((set) => ({
gdprAuthorErasureEnabled: false,
setGdprAuthorErasureEnabled: (gdprAuthorErasureEnabled)=>set({gdprAuthorErasureEnabled}),
}));
+
+export const useResolvedAt = (path: JSONPath): unknown =>
+ useStore(s => resolveByPath(s.resolved, path));
diff --git a/admin/src/utils/__tests__/resolveByPath.test.ts b/admin/src/utils/__tests__/resolveByPath.test.ts
new file mode 100644
index 00000000000..3b56304beff
--- /dev/null
+++ b/admin/src/utils/__tests__/resolveByPath.test.ts
@@ -0,0 +1,41 @@
+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', () => {
+ assert.equal(resolveByPath({xs: [10, 20]}, ['xs', '1']), 20);
+});
diff --git a/admin/src/utils/resolveByPath.ts b/admin/src/utils/resolveByPath.ts
new file mode 100644
index 00000000000..b8391c131b7
--- /dev/null
+++ b/admin/src/utils/resolveByPath.ts
@@ -0,0 +1,17 @@
+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(seg)];
+ }
+ }
+ return cur;
+};
diff --git a/docs/superpowers/plans/2026-05-18-admin-settings-resolved-runtime.md b/docs/superpowers/plans/2026-05-18-admin-settings-resolved-runtime.md
new file mode 100644
index 00000000000..4ee0ea62d93
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-18-admin-settings-resolved-runtime.md
@@ -0,0 +1,1198 @@
+# admin/settings resolved runtime values — Implementation Plan
+
+> **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](https://github.com/ether/etherpad/issues/7803)
+
+---
+
+## File Structure
+
+**New files:**
+- `src/node/utils/AdminSettingsRedact.ts` — pure redactor
+- `src/tests/backend/specs/admin/adminSettingsRedact.ts` — mocha unit tests
+- `src/tests/backend/specs/admin/adminSettingsResolved.ts` — mocha socket integration test
+- `admin/src/utils/resolveByPath.ts` — JSON path walker
+- `admin/src/utils/__tests__/resolveByPath.test.ts` — `node:test` unit tests
+- `admin/src/components/settings/widgets/__tests__/EnvPill.test.tsx` — `node:test` component tests
+- `src/tests/frontend/specs/admin-settings-resolved.spec.ts` — Playwright e2e
+
+**Modified files:**
+- `src/node/hooks/express/adminsettings.ts` — emit `resolved` field
+- `admin/src/store/store.ts` — store + selector for `resolved`
+- `admin/src/components/settings/widgets/EnvPill.tsx` — add `resolvedValue` prop + chip
+- `admin/src/components/settings/JsoncNode.tsx` — pass resolved value to EnvPill
+- `src/locales/en.json` — new i18n keys
+
+---
+
+## Task 1: Redactor — failing test
+
+**Files:**
+- Create: `src/tests/backend/specs/admin/adminSettingsRedact.ts`
+
+- [ ] **Step 1: Write the failing test file**
+
+```ts
+'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');
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+```bash
+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 mocha` is not the project's way to invoke mocha, mirror whatever pattern the sibling tests use — check `package.json` script `test:backend` and other files in `src/tests/backend/specs/admin/` to find the canonical invocation.
+
+---
+
+## Task 2: Redactor — implementation
+
+**Files:**
+- Create: `src/node/utils/AdminSettingsRedact.ts`
+
+- [ ] **Step 1: Implement the redactor**
+
+```ts
+// 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> = [
+ ['users', '*', 'password'],
+ ['users', '*', 'passwordHash'],
+ ['users', '*', 'hash'],
+ ['dbSettings', 'password'],
+ ['dbSettings', 'user'],
+ ['sso', 'clients', '*', 'client_secret'],
+ ['sso', 'clients', '*', 'secret'],
+ ['sessionKey'],
+];
+
+const pathMatches = (path: ReadonlyArray): 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 = {};
+ for (const [k, v] of Object.entries(value as Record)) {
+ 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, []);
+```
+
+- [ ] **Step 2: Run tests to verify all pass**
+
+```bash
+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.
+
+- [ ] **Step 3: Commit**
+
+```bash
+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)
+EOF
+)"
+```
+
+---
+
+## Task 3: Wire redactor into adminsettings socket
+
+**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:
+
+```ts
+import {redactSettings} from '../../utils/AdminSettingsRedact';
+```
+
+Replace the `socket.on('load')` handler (lines 54-70) with:
+
+```ts
+ socket.on('load', async (query: string): Promise => {
+ 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});
+ }
+ });
+```
+
+- [ ] **Step 2: TypeScript check**
+
+```bash
+cd /home/jose/etherpad/etherpad-issue-7803/src && pnpm exec tsc --noEmit
+```
+
+Expected: no errors related to adminsettings.ts.
+
+---
+
+## Task 4: Backend integration test — resolved field is emitted
+
+**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.
+
+```ts
+'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((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((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;
+ }
+ });
+});
+```
+
+- [ ] **Step 2: Run the full admin backend suite to make sure nothing regressed**
+
+```bash
+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.json` `scripts.test:backend` for the canonical command.
+
+- [ ] **Step 3: Commit**
+
+```bash
+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)
+EOF
+)"
+```
+
+---
+
+## Task 5: Client — resolveByPath helper + test
+
+**Files:**
+- Create: `admin/src/utils/resolveByPath.ts`
+- Create: `admin/src/utils/__tests__/resolveByPath.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// 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);
+});
+```
+
+- [ ] **Step 2: Run to verify fail**
+
+```bash
+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'`).
+
+- [ ] **Step 3: Implement**
+
+```ts
+// 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(seg)];
+ }
+ }
+ return cur;
+};
+```
+
+- [ ] **Step 4: Run again to verify pass**
+
+```bash
+cd /home/jose/etherpad/etherpad-issue-7803/admin && pnpm exec tsx --test src/utils/__tests__/resolveByPath.test.ts
+```
+
+Expected: all 8 tests PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+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)
+EOF
+)"
+```
+
+---
+
+## Task 6: Client — store wires up `resolved`
+
+**Files:**
+- Modify: `admin/src/store/store.ts`
+
+- [ ] **Step 1: Read the current store**
+
+```bash
+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.
+
+- [ ] **Step 2: Add `resolved` field, setter, selector hook**
+
+In `admin/src/store/store.ts`:
+
+Add to the state shape (alongside `settings`):
+```ts
+resolved: unknown | null;
+setResolved: (r: unknown | null) => void;
+```
+
+Add to the store implementation initial state:
+```ts
+resolved: null,
+setResolved: (resolved) => set({resolved}),
+```
+
+In the socket `'settings'` listener (where `setSettings(payload.results)` lives), add:
+```ts
+useStore.getState().setResolved(payload.resolved ?? null);
+```
+
+At the bottom of the file (or wherever existing selector hooks live), add:
+```ts
+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 `JSONPath` or `resolveByPath`, dedupe. If the file's pattern groups selectors elsewhere, follow that. Don't unilaterally refactor the file.
+
+- [ ] **Step 3: Type-check**
+
+```bash
+cd /home/jose/etherpad/etherpad-issue-7803/admin && pnpm exec tsc --noEmit
+```
+
+Expected: no errors.
+
+---
+
+## Task 7: i18n keys
+
+**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:
+
+```json
+ "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.",
+```
+
+- [ ] **Step 2: Verify JSON parses**
+
+```bash
+node -e "JSON.parse(require('fs').readFileSync('/home/jose/etherpad/etherpad-issue-7803/src/locales/en.json'))"
+```
+
+Expected: no output (no syntax error).
+
+---
+
+## Task 8: EnvPill — failing test for resolved chip
+
+**Files:**
+- Create: `admin/src/components/settings/widgets/__tests__/EnvPill.test.tsx`
+
+- [ ] **Step 1: Check what testing-library setup admin uses**
+
+```bash
+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/react` is 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 uses `react-dom/server.renderToStaticMarkup` which needs no extra deps.
+
+- [ ] **Step 2: Write the test using renderToStaticMarkup**
+
+```tsx
+// 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'));
+});
+```
+
+- [ ] **Step 3: Run to verify fail**
+
+```bash
+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).
+
+---
+
+## Task 9: EnvPill — implementation
+
+**Files:**
+- Modify: `admin/src/components/settings/widgets/EnvPill.tsx`
+
+- [ ] **Step 1: Add the `resolvedValue` prop and chip rendering**
+
+Replace the entire file with:
+
+```tsx
+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 (
+
+ ⓔ
+ {placeholder.variable}
+
+ {t('admin_settings.env_pill.default_label')}
+
+ { focused.current = true; }}
+ onBlur={() => { focused.current = false; }}
+ onChange={e => {
+ const v = sanitize(e.target.value);
+ setDraft(v);
+ onChange(v);
+ }}
+ />
+ {hasResolved && !isRedacted && (
+
+ →
+
+ {t('admin_settings.env_pill.runtime_label')}
+
+
+ {formatDisplay(resolvedValue)}
+
+
+ )}
+ {isRedacted && (
+
+ → ••••••
+
+ )}
+
+ );
+};
+```
+
+- [ ] **Step 2: Run tests to verify pass**
+
+```bash
+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.
+
+- [ ] **Step 3: Commit**
+
+```bash
+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)
+EOF
+)"
+```
+
+---
+
+## Task 10: Wire JsoncNode to pass resolved value into EnvPill
+
+**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:
+```ts
+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:
+
+```tsx
+// 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 (
+ onEdit(path, `\${${env.variable}:${d}}`)}
+ resolvedValue={isEnvPlaceholder ? resolvedForPath : undefined}
+ />
+ );
+ }
+ return (
+ 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:** `useResolvedAt` is a hook and can only be called inside a component, not inside `renderLeaf` (a free function). The branch above keeps the rest of `renderLeaf` untouched so the diff stays small.
+
+- [ ] **Step 2: Type-check + lint**
+
+```bash
+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.
+
+- [ ] **Step 3: Commit**
+
+```bash
+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)
+EOF
+)"
+```
+
+---
+
+## Task 11: Minimal CSS for runtime chip
+
+**Files:**
+- Modify: whichever stylesheet currently styles `.settings-widget-env-*` (grep to find it)
+
+- [ ] **Step 1: Locate the existing env-pill styles**
+
+```bash
+cd /home/jose/etherpad/etherpad-issue-7803/admin && grep -rn "settings-widget-env" src/ --include="*.css" --include="*.scss"
+```
+
+- [ ] **Step 2: Append minimal styling for the runtime chip**
+
+Add adjacent to the existing env-pill rules:
+
+```css
+.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.
+
+- [ ] **Step 3: Commit**
+
+```bash
+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)
+EOF
+)"
+```
+
+---
+
+## Task 12: End-to-end test in a browser
+
+**Files:**
+- Create: `src/tests/frontend/specs/admin-settings-resolved.spec.ts`
+
+- [ ] **Step 1: Check the existing Playwright test setup**
+
+```bash
+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.
+
+- [ ] **Step 2: Write the e2e test**
+
+```ts
+// 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
+ });
+});
+```
+
+- [ ] **Step 3: Run e2e**
+
+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:
+
+```bash
+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.ts` for `webServer.env`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+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)
+EOF
+)"
+```
+
+---
+
+## Task 13: Final verification + PR
+
+- [ ] **Step 1: Run backend tests**
+
+```bash
+cd /home/jose/etherpad/etherpad-issue-7803/src && pnpm exec mocha --require ts-node/register --recursive tests/backend/specs/admin/
+```
+
+Expected: all PASS.
+
+- [ ] **Step 2: Run admin frontend tests**
+
+```bash
+cd /home/jose/etherpad/etherpad-issue-7803/admin && pnpm test
+```
+
+Expected: all PASS.
+
+- [ ] **Step 3: Run lint and tsc**
+
+```bash
+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.
+
+- [ ] **Step 4: Push branch and open PR**
+
+```bash
+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
+)"
+```
+
+- [ ] **Step 5: Wait + check CI**
+
+Per [[feedback_check_ci_after_pr]]: wait ~20s, then:
+
+```bash
+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.
+
+---
+
+## Self-Review Notes
+
+- **Spec coverage:** Every section of the spec maps to at least one task (redactor → Task 1+2, emit site → Task 3, backend integration → Task 4, resolveByPath → Task 5, store + selector → Task 6, EnvPill → Tasks 7-9, JsoncNode wiring → Task 10, CSS → Task 11, e2e → Task 12, PR + CI → Task 13). FormView dropdown changes from the spec are intentionally dropped because the current FormView has no enum dropdowns — only the EnvPill (which we are fixing) renders env-substituted strings.
+- **No placeholders:** every step has either concrete code or a concrete command. Discovery steps (e.g. "check existing Playwright config") are bounded with a concrete next action.
+- **Type consistency:** `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.
diff --git a/docs/superpowers/specs/2026-05-18-admin-settings-resolved-runtime-design.md b/docs/superpowers/specs/2026-05-18-admin-settings-resolved-runtime-design.md
new file mode 100644
index 00000000000..2bad8eb0d24
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-18-admin-settings-resolved-runtime-design.md
@@ -0,0 +1,280 @@
+# /admin/settings — emit resolved runtime values alongside raw file
+
+**Issue:** [ether/etherpad#7803](https://github.com/ether/etherpad/issues/7803)
+**Date:** 2026-05-18
+
+## Problem
+
+`/admin/settings` reads `settings.json` off disk and emits its bytes
+verbatim. The admin SPA then parses those bytes against its enum
+dropdowns. Anywhere `${ENV_VAR:default}` appears, the SPA can't resolve
+the variable and falls back to the template default — so operators see
+values that don't reflect what Etherpad is actually running with.
+
+Concrete example: `DB_TYPE=sqlite` in the container env, settings file
+contains `"dbType": "${DB_TYPE:dirty}"`. Admin UI shows the DB Type
+dropdown selected on `dirty`. Etherpad is genuinely on SQLite. The
+admin UI is lying.
+
+This is the only built-in way operators have to verify runtime config,
+so a fix is overdue.
+
+## Goals
+
+1. The admin UI accurately shows what `settings.*` values Etherpad is
+ actually using right now, including env-var-substituted values.
+2. The raw textarea and `saveSettings` round-trip preserve the original
+ `${VAR:default}` literals so an admin can still edit the template
+ without baking env vars into the file.
+3. Secrets that would otherwise leak (passwords, OIDC client secrets,
+ session-signing material) are redacted from the resolved payload.
+
+## Non-goals
+
+- Rewriting the save path so admins can edit through the form view
+ without touching env-var bindings. That's a larger UX rework — see
+ Future Work.
+- Changing `settings.json.template` or `settings.json.docker` on disk.
+- Touching ep_kaput.
+
+## Design
+
+### Architecture
+
+Extend the existing `'load'` socket handler in
+`src/node/hooks/express/adminsettings.ts` to emit one additional
+field next to the existing `results` blob:
+
+```ts
+socket.emit('settings', {
+ results: rawFileString, // unchanged — for textarea + saveSettings
+ resolved: redactedRuntimeObject, // new — parsed object, env vars resolved, secrets redacted
+ flags, // unchanged
+});
+```
+
+`resolved` is the in-memory `settings` module (which already had
+`lookupEnvironmentVariables` applied to it at boot) passed through a
+recursive redactor. Old clients that ignore `resolved` continue to
+work unchanged. The `saveSettings` handler is not touched, so the
+file's `${VAR:default}` literals survive save round-trips.
+
+### Server: redactor
+
+New module `src/node/utils/AdminSettingsRedact.ts` exporting:
+
+```ts
+export function redactSettings(settings: unknown): unknown
+```
+
+A pure function that takes the in-memory settings object, deep-clones
+it (Node's built-in `structuredClone`, available since Node 17),
+walks the clone, and replaces values at known sensitive JSON paths
+with the sentinel string `"[REDACTED]"`. The original object is not
+mutated. Functions on the live `settings` module — `coerceValue`,
+`reloadSettings`, etc. — are dropped during the clone walk by
+filtering them out before recursion (structured clone rejects
+functions); the resolved payload contains only data.
+
+Allow-list (paths use `*` for any object key and `[*]` for any array
+index):
+
+| Path | Reason |
+|---|---|
+| `users.*.password` | Plaintext basic-auth password |
+| `users.*.passwordHash` | Bcrypt hash — credential material |
+| `users.*.hash` | Older spelling used by some configs |
+| `dbSettings.password` | DB password (mysql/postgres/redis) |
+| `dbSettings.user` | Credential half — redact for symmetry |
+| `sso.clients[*].client_secret` | OIDC client secret |
+| `sso.clients[*].secret` | ep_openid_connect older spelling |
+| `sso.issuer` | Only if URL contains `user:pass@` userinfo |
+| `loadTest.*.password` | ep_load_test creds |
+| `sessionKey` | Used to sign session cookies |
+
+Behaviour:
+- Redact to the literal string `"[REDACTED]"` regardless of original
+ type, so the SPA only ever has to check one sentinel.
+- A redacted leaf is redacted only at the leaf — siblings stay
+ visible (e.g. `sso.clients[0].client_id` is shown,
+ `sso.clients[0].client_secret` is redacted).
+- If the underlying value was `null` (env var unset, no default),
+ still emit `"[REDACTED]"` to avoid leaking "this secret is unset"
+ via a visible `null`.
+- `dbSettings.filename` is NOT redacted — operators need to verify
+ their volume mount.
+
+### Server: emit site
+
+In `adminsettings.ts`, in `socket.on('load')`:
+
+```ts
+import {redactSettings} from '../../utils/AdminSettingsRedact';
+// ...
+const resolved = redactSettings(settings);
+socket.emit('settings', {results: rawFileString, resolved, flags});
+```
+
+If `showSettingsInAdminPage === false`, omit `resolved` too (don't
+emit a redacted runtime when the file blob is gated).
+
+### Client: store
+
+`admin/src/store/store.ts`:
+- Add `resolved: unknown | null` to the store, defaulting to `null`.
+- In the `'settings'` socket listener, store `payload.resolved ?? null`.
+- Old servers that don't send `resolved` leave it `null` and the UI
+ degrades to current behaviour.
+
+`admin/src/utils/resolveByPath.ts` (new file — single-purpose helper):
+- Export `resolveByPath(obj: unknown, path: JSONPath): unknown` —
+ walks a plain JS object by a `jsonc-parser` JSONPath
+ (`(string | number)[]`), returns `undefined` on miss. Pure,
+ unit-tested.
+
+`admin/src/store/store.ts`:
+- Add a `useResolvedAt(path: JSONPath)` selector hook that returns
+ `resolveByPath(state.resolved, path)`.
+
+### Client: env pill widget
+
+`admin/src/components/settings/widgets/EnvPill.tsx`:
+- New optional prop `resolvedValue?: unknown`.
+- When defined and not `'[REDACTED]'`: render a read-only chip after
+ the editable default input, e.g. `→ sqlite`.
+- When `'[REDACTED]'`: render `→ ••••••` with an i18n tooltip
+ explaining the value is redacted.
+- When `undefined` (old server or missing path): no chip; render
+ exactly as today.
+
+New i18n key `admin_settings.env_pill.runtime_label` and
+`admin_settings.env_pill.redacted_tooltip`. No hardcoded English.
+
+### Client: jsonc tree
+
+`admin/src/components/settings/JsoncNode.tsx`:
+- Where `matchEnvPlaceholder(raw)` is currently called (line ~42),
+ also call `useResolvedAt(path)` and pass the result as
+ `resolvedValue` to ``.
+
+### Client: form view
+
+`admin/src/components/settings/FormView.tsx`:
+- FormView already has access to the parsed JSONC tree through
+ `rawText = useStore(s => s.settings)` plus jsonc-parser. For each
+ control, after detecting that the raw value at its path is an env
+ placeholder (`matchEnvPlaceholder` on the raw slice), the
+ *selected* dropdown option / displayed input value is derived from
+ `useResolvedAt(path)` rather than from the literal placeholder
+ string. Plain (non-placeholder) values keep using the raw JSONC
+ value as today.
+- The env pill above the control still shows and is still editable
+ (the editable input mutates the `default` portion of the
+ placeholder in the raw JSONC, exactly as today).
+- This is the fix for the original "dropdown shows `dirty` when DB is
+ sqlite" complaint.
+
+## Data flow
+
+```
+boot
+ └─ Settings.ts:reloadSettings()
+ └─ lookupEnvironmentVariables(parsedSettings) -- ${VAR:default} → real value
+ └─ writes into the exported `settings` module
+
+admin loads /admin/settings
+ └─ socket 'load'
+ ├─ fsp.readFile(settings.settingsFilename) -- raw, env vars unresolved
+ └─ redactSettings(settings) -- live module, env vars resolved, secrets redacted
+ socket.emit('settings', {results: raw, resolved, flags})
+
+admin SPA
+ ├─ raw textarea ← results -- preserves ${VAR:default}
+ ├─ env pill chip ← resolveByPath(resolved, path) -- shows "→ sqlite"
+ └─ form view dropdown selection ← resolveByPath(resolved, path)
+
+admin clicks Save
+ └─ saveSettings emits the raw textarea blob (unchanged behaviour)
+ └─ server writes verbatim to settings.json — template intact
+```
+
+## Error handling
+
+- `redactSettings` is a pure function over a structured clone; no I/O,
+ no rejection path. If it throws on a malformed live `settings`
+ module (shouldn't happen — that module is the source of truth at
+ runtime) we let the error propagate; the `socket.on('load')`
+ handler already has no try/catch around the emit and any throw will
+ surface in logs.
+- On the client, `resolveByPath` returns `undefined` for missing
+ paths. Consumers treat `undefined` as "no resolved value
+ available" and fall back to current behaviour.
+- Old client + new server: client ignores `resolved` — degrades to
+ today's misleading UI, no regression.
+- New client + old server: `resolved` is `null` — `useResolvedAt`
+ returns `undefined` everywhere, env pill skips the chip, dropdowns
+ fall back to current behaviour.
+
+## Testing
+
+Per the `feedback_always_run_backend_tests` and
+`feedback_test_localized_strings` memories.
+
+**Backend vitest** (`src/tests/backend/specs/`):
+- `AdminSettingsRedact.spec.ts` — fixture per allow-list path, plus
+ a no-op control case, plus a nested-secret-inside-an-array case.
+- `adminsettings.spec.ts` — mock socket, set `process.env.DB_TYPE=sqlite`,
+ call `reloadSettings()`, emit `load`, assert `resolved.dbType === 'sqlite'`
+ and `resolved.dbSettings.password === '[REDACTED]'`.
+
+**Frontend vitest** (`admin/src/`):
+- `utils/resolveByPath.spec.ts` — nested objects, arrays, missing keys.
+- `widgets/EnvPill.spec.tsx` — renders chip when value set, renders
+ redacted chip when sentinel, omits chip when undefined.
+
+**Playwright e2e** (`src/tests/frontend/specs/` or `src/tests/admin/`):
+- Boot Etherpad with `DB_TYPE=sqlite` env on port 9003 (per
+ `feedback_test_port_9003`).
+- Open `/admin/settings`, switch to form view.
+- Assert DB Type dropdown reflects `sqlite`, not `dirty`.
+- Switch to raw view, assert env pill chip shows `→ sqlite`.
+
+## Docs
+
+Per `feedback_include_docs_updates`:
+- Identify the existing admin-settings doc path during implementation
+ (likely `doc/admin/admin-settings.md` or under `doc/api/`) — add a
+ short section on the resolved-value chip and the `[REDACTED]`
+ sentinel. If no such doc exists yet, no new doc is created in this
+ PR (avoid the "don't create docs unless asked" rule); instead, a
+ pointer in the PR description.
+- Follow-up PR to `ether/home-assistant-addon-etherpad/etherpad/DOCS.md`
+ to remove the "admin settings page is cosmetic" caveat once this
+ ships; reference from the etherpad PR description.
+
+## Future work
+
+- Form-view save path that lets an admin edit a value without
+ touching its env-var binding. Today FormView writes back to raw
+ JSONC; the env-pill default input is the only way to edit an
+ env-bound value, which is awkward for non-string values.
+- An audit log of redacted-vs-visible keys so plugins can declare
+ additional secret paths via a hook.
+
+## Implementation footprint
+
+- New file `src/node/utils/AdminSettingsRedact.ts` (~80 lines + JSDoc).
+- `src/node/hooks/express/adminsettings.ts` — ~4 lines changed.
+- New file `admin/src/utils/resolveByPath.ts` (~15 lines).
+- `admin/src/store/store.ts` — ~10 lines changed (store field,
+ selector hook).
+- `admin/src/components/settings/widgets/EnvPill.tsx` — ~15 lines
+ added (prop, chip render).
+- `admin/src/components/settings/JsoncNode.tsx` — ~3 lines changed.
+- `admin/src/components/settings/FormView.tsx` — modest changes per
+ control type; estimate ~30 lines.
+- New i18n keys in `src/locales/en.json` (English source).
+- Tests: ~4 spec files, ~200 lines.
+- Docs: ~30 lines.
+
+Total: ~400 lines including tests and docs.
diff --git a/src/locales/en.json b/src/locales/en.json
index 22b26ec8d03..9fc2db42872 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -139,6 +139,9 @@
"admin_settings.env_pill.tooltip": "Reads from the {{variable}} environment variable. The value below is used when {{variable}} is unset.",
"admin_settings.env_pill.default_label": "default",
"admin_settings.env_pill.input_aria": "Default value for {{variable}}",
+ "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.",
"admin_settings.page-title": "Settings - Etherpad",
"admin_settings.save_error": "Error saving settings",
"admin_settings.saved_success": "Successfully saved settings",
diff --git a/src/node/hooks/express/adminsettings.ts b/src/node/hooks/express/adminsettings.ts
index a5b20bfdd58..04f101cf828 100644
--- a/src/node/hooks/express/adminsettings.ts
+++ b/src/node/hooks/express/adminsettings.ts
@@ -9,6 +9,7 @@ const hooks = require('../../../static/js/pluginfw/hooks');
const plugins = require('../../../static/js/pluginfw/plugins');
import settings, {getEpVersion, getGitCommit, reloadSettings} from '../../utils/Settings';
import {getLatestVersion} from '../../utils/UpdateCheck';
+import {redactSettings} from '../../utils/AdminSettingsRedact';
const padManager = require('../../db/PadManager');
const api = require('../../db/API');
import {deleteRevisions} from '../../utils/Cleanup';
@@ -65,7 +66,8 @@ exports.socketio = (hookName: string, {io}: any) => {
if (settings.showSettingsInAdminPage === false) {
socket.emit('settings', {results: 'NOT_ALLOWED', flags});
} else {
- socket.emit('settings', {results: data, flags});
+ const resolved = redactSettings(settings);
+ socket.emit('settings', {results: data, resolved, flags});
}
});
diff --git a/src/node/utils/AdminSettingsRedact.ts b/src/node/utils/AdminSettingsRedact.ts
new file mode 100644
index 00000000000..8bdaebf03d1
--- /dev/null
+++ b/src/node/utils/AdminSettingsRedact.ts
@@ -0,0 +1,50 @@
+// 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> = [
+ ['users', '*', 'password'],
+ ['users', '*', 'passwordHash'],
+ ['users', '*', 'hash'],
+ ['dbSettings', 'password'],
+ ['dbSettings', 'user'],
+ ['sso', 'clients', '*', 'client_secret'],
+ ['sso', 'clients', '*', 'secret'],
+ ['sessionKey'],
+];
+
+const pathMatches = (path: ReadonlyArray): 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 = {};
+ for (const [k, v] of Object.entries(value as Record)) {
+ const child = walk(v, [...path, k]);
+ if (child !== undefined) out[k] = child;
+ }
+ return out;
+ }
+ return value;
+};
+
+export const redactSettings = (settings: unknown): unknown => walk(settings, []);
diff --git a/src/tests/backend/specs/admin/adminSettingsRedact.ts b/src/tests/backend/specs/admin/adminSettingsRedact.ts
new file mode 100644
index 00000000000..a4149fd62f9
--- /dev/null
+++ b/src/tests/backend/specs/admin/adminSettingsRedact.ts
@@ -0,0 +1,101 @@
+'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);
+ 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');
+ });
+
+ 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 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('only matches the exact JSON path, not deeper matches', function () {
+ const out = redactSettings({
+ sso: {clients: [{nested: {client_secret: 'nope'}}]},
+ }) as any;
+ assert.equal(out.sso.clients[0].nested.client_secret, 'nope');
+ });
+});
diff --git a/src/tests/backend/specs/admin/adminSettingsResolved.ts b/src/tests/backend/specs/admin/adminSettingsResolved.ts
new file mode 100644
index 00000000000..9d95f089fcb
--- /dev/null
+++ b/src/tests/backend/specs/admin/adminSettingsResolved.ts
@@ -0,0 +1,181 @@
+'use strict';
+
+import {strict as assert} from 'assert';
+import setCookieParser from 'set-cookie-parser';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+
+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 savedRequireAuthentication = 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 = savedRequireAuthentication;
+ }
+ 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((res, rej) => {
+ const onErr = (err: any) => { socket.off('connect', onConn); rej(err); };
+ const onConn = () => { socket.off('connect_error', onErr); res(); };
+ socket.once('connect', onConn);
+ socket.once('connect_error', onErr);
+ });
+ return socket;
+};
+
+// Probe modeled on anonymizeAuthorSocket.ts — when an authenticate-hook
+// plugin (e.g. ep_hash_auth) rejects plain-text test creds, the /settings
+// connection handler never registers listeners and every emit hangs.
+// `load` is the simplest event with a matching reply on this namespace.
+const PROBE_BUDGET_MS = 15000;
+const adminSocketWithProbe = async (budgetMs: number): Promise<{
+ ok: true; socket: any;
+} | {ok: false; reason: string;}> => {
+ const deadline = Date.now() + budgetMs;
+ let socket: any;
+ try {
+ socket = await Promise.race([
+ adminSocket(),
+ new Promise((_, rej) =>
+ setTimeout(() => rej(new Error('adminSocket connect timed out')),
+ Math.max(0, deadline - Date.now()))),
+ ]);
+ } catch (err: any) {
+ return {ok: false, reason: String(err && err.message || err)};
+ }
+ const remaining = Math.max(0, deadline - Date.now());
+ const replied = new Promise((res) => socket.once('settings', () => res(true)));
+ socket.emit('load', null);
+ const probed = await Promise.race([
+ replied,
+ new Promise((res) => setTimeout(() => res(false), remaining)),
+ ]);
+ if (!probed) {
+ socket.disconnect();
+ return {ok: false, reason: `no \`settings\` reply within ${budgetMs}ms`};
+ }
+ return {ok: true, socket};
+};
+
+const ask = (socket: any, evt: string, payload: any, replyEvt: string) =>
+ new Promise((res) => {
+ socket.once(replyEvt, res);
+ socket.emit(evt, payload);
+ });
+
+describe(__filename, function () {
+ let socket: any;
+ let savedUsers: any;
+ let savedRequireAuthentication: boolean;
+ let savedDbPwd: any;
+ let savedTrustProxy: any;
+ let savedSessionKey: any;
+ let savedShow: any;
+ let savedSettingsFilename: any;
+ let tmpSettingsPath: string | null = null;
+ let setupCompleted = false;
+
+ before(async function () {
+ this.timeout(60000);
+ await common.init();
+
+ // The load handler bails with logger.error + early return if the
+ // file is missing, so make sure something is on disk for it to read.
+ savedSettingsFilename = settings.settingsFilename;
+ tmpSettingsPath = path.join(os.tmpdir(),
+ `etherpad-7803-settings-${process.pid}.json`);
+ fs.writeFileSync(tmpSettingsPath,
+ '{\n "_comment": "stub settings.json for adminSettingsResolved.ts"\n}\n');
+ settings.settingsFilename = tmpSettingsPath;
+
+ savedUsers = settings.users;
+ savedRequireAuthentication = settings.requireAuthentication;
+ settings.dbSettings = settings.dbSettings || {};
+ savedDbPwd = settings.dbSettings.password;
+ savedTrustProxy = settings.trustProxy;
+ savedSessionKey = settings.sessionKey;
+ savedShow = settings.showSettingsInAdminPage;
+ // Mutate the in-memory module so we can prove `resolved` reflects
+ // the runtime, not the file on disk.
+ settings.dbSettings.password = 'live-db-password';
+ settings.trustProxy = true;
+ settings.sessionKey = 'live-session-key';
+ setupCompleted = true;
+
+ const probe = await adminSocketWithProbe(PROBE_BUDGET_MS);
+ if (!probe.ok) {
+ console.warn(
+ `[adminSettingsResolved] admin socket probe failed (${probe.reason}); ` +
+ 'skipping suite — likely an authenticate-hook plugin rejecting test creds.');
+ this.skip();
+ return;
+ }
+ socket = probe.socket;
+ });
+
+ after(function () {
+ if (socket) socket.disconnect();
+ if (!setupCompleted) return;
+ if (savedDbPwd === undefined) delete settings.dbSettings.password;
+ else settings.dbSettings.password = savedDbPwd;
+ settings.trustProxy = savedTrustProxy;
+ settings.sessionKey = savedSessionKey;
+ settings.showSettingsInAdminPage = savedShow;
+ settings.settingsFilename = savedSettingsFilename;
+ if (tmpSettingsPath) {
+ try { fs.unlinkSync(tmpSettingsPath); } catch { /* best effort */ }
+ }
+ if (settings.users) delete settings.users['test-admin'];
+ settings.users = savedUsers;
+ settings.requireAuthentication = savedRequireAuthentication;
+ });
+
+ 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 in-memory 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 () {
+ 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;
+ }
+ });
+});