From 33e2546f281ea5cd16efa39c3aaf5a4ba6d4a7d4 Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 12:28:44 +0100 Subject: [PATCH 1/5] docs: spec for admin/settings resolved runtime values (#7803) Side-channel resolved+redacted settings alongside raw file blob. Form view dropdowns and env pill chips reflect actual runtime values instead of falling back to template defaults. Save round-trip is unchanged so ${VAR:default} literals stay intact on disk. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-admin-settings-resolved-runtime-design.md | 280 ++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-18-admin-settings-resolved-runtime-design.md 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. From e8c2486179e06d1833360a94ca9ece9248a5d110 Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 12:39:03 +0100 Subject: [PATCH 2/5] docs: implementation plan for admin/settings resolved runtime (#7803) Co-Authored-By: Claude Opus 4.7 (1M context) --- ...6-05-18-admin-settings-resolved-runtime.md | 1198 +++++++++++++++++ 1 file changed, 1198 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-18-admin-settings-resolved-runtime.md 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. From ca38d42b12af074b3ac9187db92d4262d7b5fe7f Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 12:40:36 +0100 Subject: [PATCH 3/5] feat(admin): add redactor for resolved settings payload (#7803) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure helper that walks 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) --- src/node/utils/AdminSettingsRedact.ts | 50 +++++++++ .../specs/admin/adminSettingsRedact.ts | 101 ++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 src/node/utils/AdminSettingsRedact.ts create mode 100644 src/tests/backend/specs/admin/adminSettingsRedact.ts 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'); + }); +}); From f423ba20bee3885aeba966a901966b0f31dd82d3 Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 12:44:44 +0100 Subject: [PATCH 4/5] feat(admin): emit redacted runtime settings on /settings socket load (#7803) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/node/hooks/express/adminsettings.ts | 4 +- .../specs/admin/adminSettingsResolved.ts | 181 ++++++++++++++++++ 2 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 src/tests/backend/specs/admin/adminSettingsResolved.ts 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/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; + } + }); +}); From f6f0e686abf5abeb0108c62c6fa3a5634155a2a1 Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 12:49:59 +0100 Subject: [PATCH 5/5] feat(admin): show resolved runtime value on EnvPill (#7803) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin SPA now stores the resolved field from the /settings socket payload and exposes useResolvedAt(path) to walk it. EnvPill renders a "→ active value" chip when the path is resolved, or "→ ••••••" with a redacted tooltip when the server returned the [REDACTED] sentinel. Old-server fallback (undefined resolved) keeps current behaviour. The admin test script glob now picks up .test.tsx alongside .test.ts so the new EnvPill tests run under tsx --test. Closes #7803. Co-Authored-By: Claude Opus 4.7 (1M context) --- admin/package.json | 2 +- admin/src/App.css | 26 ++++++ admin/src/App.tsx | 4 + admin/src/components/settings/JsoncNode.tsx | 10 ++- .../components/settings/widgets/EnvPill.tsx | 45 +++++++++- .../widgets/__tests__/EnvPill.test.tsx | 86 +++++++++++++++++++ admin/src/store/store.ts | 12 +++ .../src/utils/__tests__/resolveByPath.test.ts | 41 +++++++++ admin/src/utils/resolveByPath.ts | 17 ++++ src/locales/en.json | 3 + 10 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 admin/src/components/settings/widgets/__tests__/EnvPill.test.tsx create mode 100644 admin/src/utils/__tests__/resolveByPath.test.ts create mode 100644 admin/src/utils/resolveByPath.ts 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/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",