* chore(admin): typesafe API client + TanStack Query rails (#7638) (#2)
* docs(admin): design for typesafe API client + TanStack Query rails (#7638)
Rails-only scope: codegen toolchain, runtime client, and provider — no
call-site migrations. Admin endpoints are not yet covered by the OpenAPI
spec, so a separate issue will follow before any migration is useful.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(admin): implementation plan for typesafe API client rails (#7638)
Step-by-step task breakdown for the rails-only PR: codegen toolchain,
runtime client, TanStack Query provider, CI freshness check, docs. No
call-site migrations until admin endpoints are added to the OpenAPI
spec (separate follow-up).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(api): export generateDefinitionForVersion from openapi hook
Required by the admin codegen script (#7638) to dump the OpenAPI spec
without booting Express. No behavior change for the request hook.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(admin): add OpenAPI codegen + TanStack Query deps (#7638)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(admin): add OpenAPI spec dump entry (#7638)
Loaded via tsx by gen-api.mjs in the next commit. Writes JSON to a file
path argument so log4js stdout output (from Settings init) does not
pollute the spec output.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(admin): wire OpenAPI codegen into build (#7638)
Adds gen:api script and amends build/build-copy to regenerate
admin/src/api/schema.d.ts before compiling. The generated file is
checked in so it shows up in PR review and so a fresh checkout doesn't
need codegen to typecheck.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(admin): typed openapi-fetch + react-query client (#7638)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(admin): TanStack Query provider, dev-only devtools (#7638)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(admin): mount TanStack Query provider at root (#7638)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(admin): smoke test for typed openapi-fetch client (#7638)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(admin): verify generated OpenAPI schema is up to date (#7638)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(admin): document OpenAPI codegen workflow (#7638)
Replaces the default Vite scaffold README with admin-specific scripts
table and codegen workflow notes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* build(admin): exclude __tests__ from tsc include (#7638)
The smoke test imports node:test/node:assert which need @types/node.
Admin source is browser-only, so excluding __tests__ from the production
typecheck is cleaner than adding Node types to the bundle config. The
test still runs under tsx, which doesn't share this constraint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: regenerate lockfile with pnpm 10 to restore overrides block (#7638)
Adding admin deps with pnpm 11 stripped the top-level \`overrides:\`
section from pnpm-lock.yaml, which CI uses pnpm 10 to verify. Result:
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH on every job. Re-running pnpm 10
\`install --lockfile-only\` restores the overrides block; the new admin
package entries land in the same commit. Two stale lockfile entries
not present in package.json (\`serialize-javascript\` version pin and
\`uuid@<14.0.0\`) were normalized by the regen — package.json is the
source of truth for those.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* build(docker): preserve strictDepBuilds=false in trimmed workspace yaml (#7638)
Adding tsx as an admin devDep brings esbuild@0.27.x into the resolved
subgraph, and the Dockerfile's runtime stage was overwriting
pnpm-workspace.yaml with a stripped-down version that lost the
strictDepBuilds=false setting from the source repo. With pnpm 10's
default of strictDepBuilds=true, the install then errors on
ERR_PNPM_IGNORED_BUILDS for esbuild + scarf rather than warning.
Restore the strictDepBuilds=false and the @scarf/scarf ignore in the
trimmed yaml so the production install matches develop's behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(admin): point client baseUrl at /api/<version> via codegen (#7638)
Qodo flagged: with baseUrl='/' and schema paths like '/createGroup',
calls landed at /createGroup, but the backend mounts the FLAT-style
spec under /api/<version>/. So once a call site lands, every request
404s.
gen:api now also emits admin/src/api/version.ts containing
LATEST_API_VERSION (read from info.version in the spec) and a derived
API_BASE_URL = `/api/<version>`. client.ts imports API_BASE_URL.
Workflow freshness check covers both generated files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* build(admin): cross-platform spawn in gen-api.mjs (#7638)
Windows CI failed because spawnSync('pnpm', ...) cannot resolve
pnpm.cmd without a shell. Set shell:true on win32 only so Linux/macOS
runs avoid Node's DEP0190 warning. All spawn args are literal strings,
so the shell variant is not an injection risk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(admin): gitignore generated schema/version, regen on every script (#7638)
Qodo flagged the committed admin/src/api/schema.d.ts as a build artifact
that violates the rule against committing generated files (rule 467291,
"Exclude build artifacts and runtime-generated files from version control").
This commit:
- Adds admin/src/api/{schema.d.ts,version.ts} to .gitignore.
- Removes both from VCS (the prior squash had committed them).
- Chains \`gen:api\` into the dev and test scripts so a fresh checkout
lands a working dev server / test run without an extra step. build
and build-copy already chained gen:api.
- Drops the now-redundant CI freshness diff step from
frontend-admin-tests.yml — with the files no longer committed, the
build step's gen:api invocation is the only check needed.
- Updates admin/README.md to describe the new generated-file workflow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(admin): design for admin OpenAPI coverage (#7693)
Adds the design doc for documenting `/admin-auth/*` and `/admin/update/status`
in the OpenAPI spec so the typed client generated by #7695 (`admin/src/api/
schema.d.ts`) gains admin call-sites the day it lands.
This PR is stacked on #7695 — codegen rails must merge first. Schema-only,
no call-site migrations (those are an explicit follow-up named in #7693).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(admin): correct UpdateStatus schema to base-branch shape (#7693)
The first draft mirrored the Tier 2 (#7607) response shape, but this PR
stacks on #7695 whose updateStatus.ts only emits the Tier 1 fields. Fix
the schema, install-method enum, and tier enum to match types.ts and
the actual handler. Tier 2 amends UpdateStatus when it lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(admin): implementation plan for admin OpenAPI coverage (#7693)
Step-by-step task breakdown for the schema-only PR: stub the document,
add /admin-auth/ + /admin/update/status paths and sub-schemas,
collision regression, /admin/openapi.json route, codegen merge, and CI
verification. No call-site migrations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(admin): stub OpenAPI document for admin endpoints (#7693)
Adds generateAdminDefinition() returning a minimal valid OpenAPI 3.0
document with no paths yet, plus security schemes for the two auth
modes (Basic + session cookie). Subsequent tasks fill in the actual
admin paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(admin): document POST /admin-auth/ in OpenAPI (#7693)
Adds verifyAdminAccess as the operation that the admin UI's LoginScreen
and App session check both call. Documents Basic auth, session cookie,
and anonymous request modes plus their 200/401/403 responses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(admin): document GET /admin/update/status in OpenAPI (#7693)
Adds getUpdateStatus operation plus UpdateStatus, ReleaseInfo,
PolicyResult, and VulnerableBelowDirective sub-schemas. Property names
and enums mirror src/node/updater/types.ts and the response object
emitted by updateStatus.ts. Tier 2 (#7607) will amend UpdateStatus when
it ships execution/lastResult/lockHeld.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(admin): regression net for admin/public OpenAPI collisions (#7693)
Cross-checks admin paths, operationIds, and schema names against the
latest public spec. Today there are no overlaps; the test exists to
catch future renames before they break the merged client codegen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(admin): expose admin OpenAPI doc at /admin/openapi.json (#7693)
Mounts the admin OpenAPI document at /admin/openapi.json (CORS: *) via
an expressPreSession hook, matching the /api/openapi.json convention.
The admin SPA wildcard at /admin/{*filename} registers later in
expressCreateServer, so the JSON route wins. Live-route test confirms
JSON content-type and CORS header.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(admin): mergeOpenAPI helper for codegen pipeline (#7693)
Pure-JS deep-merge of two OpenAPI 3.0 documents. Unions paths and
components by key; throws on collisions. Public document's info,
servers, and root security win over the admin document's. Used by
dump-spec.ts to produce a single merged JSON for openapi-typescript.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(admin): fix stale "Section 3" reference in spec (#7693)
Drafting numbered the design walkthrough; final spec uses titled
sections. Update the back-reference accordingly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(admin): include admin OpenAPI in generated client (#7693)
Modifies dump-spec.ts to import generateAdminDefinition alongside the
public generator and feed both through mergeOpenAPI before writing the
JSON consumed by openapi-typescript. The resulting admin/src/api/
schema.d.ts paths interface now exposes /admin-auth/ and
/admin/update/status, ready for typed call-site adoption in a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(admin): gate /admin/openapi.json behind a feature flag (#7693)
Address Qodo finding 1: new features must ship behind a flag, disabled
by default (CONTRIBUTING.md, AGENTS.MD, best_practices.md). Adds
settings.adminOpenAPI.enabled (default false). expressPreSession
returns early when the flag is off, so the route is dormant on a fresh
install.
The codegen pipeline imports generateAdminDefinition() in-process and
does not depend on the runtime route, so default-off has no effect on
the typed client. Operators who want third-party tooling (Postman,
swagger-ui, downstream clients) to consume the spec at runtime opt in
via settings.
Adds:
- SettingsType + defaults entry in src/node/utils/Settings.ts
- settings.json.template documentation
- A backend spec asserting expressPreSession is a no-op when the flag
is off (live route test now sets the flag explicitly)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(admin): split fetchClient by surface; admin client baseUrl='/' (#7693)
Address Qodo finding 2 (correctness bug). The merged schema introduced
in this PR exposes both public-API paths (under /api/<version>/) and
admin paths (at root, e.g. /admin-auth/). The single fetchClient from
#7695 has baseUrl=API_BASE_URL ("/api/<version>"), so calling an admin
path through it would resolve to /api/<version>/admin-auth/ — wrong.
Fix:
- Narrow the generated `paths` interface by URL prefix
(`/admin*` vs everything else).
- Export two clients: `fetchClient` (public, baseUrl=/api/<version>)
and `adminFetchClient` (admin, baseUrl='/').
- Mirror with `$api` / `$adminApi` query hook factories.
TypeScript now rejects mixing surfaces at compile time:
fetchClient.GET('/admin-auth/') // type error: not in PublicPaths
adminFetchClient.GET('/createGroup') // type error: not in AdminPaths
Smoke test updated to assert all four exports exist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(admin): spec — feature flag + dual-client design (#7693)
Reflect Qodo-driven changes:
- /admin/openapi.json route is now feature-flagged (default off).
- admin/src/api/client.ts splits paths by URL prefix and exports two
clients (public + admin) so TypeScript rejects mixing surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(admin): check adminOpenAPI flag per-request, not at hook setup (#7693)
The first feature-flag commit (dbcfb3902) gated the route by checking
the flag inside expressPreSession and skipping app.get() when off. That
broke under the full backend suite because common.init() is cached: the
first spec that calls it boots the server with whatever flag state was
present at that moment, and later specs cannot retroactively register
the route by toggling the flag.
Move the flag check inside the request handler:
- Route is always registered.
- Handler returns 404 application/json when the flag is off (so callers
get a clear "feature disabled" signal rather than the SPA wildcard's
text/html catch-all).
- Handler returns the spec + CORS * when the flag is on.
Tests now toggle the flag in-process and exercise both states against
the shared agent — no server restart needed. Added a "returns 404 JSON
when off" test alongside the existing "200 + spec + CORS when on".
Full backend suite: 1007 passing, 6 pending, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(export): native DOCX export via html-to-docx (opt-in)
Addresses #7538. The current DOCX export path shells out to LibreOffice,
which means every deployment that wants a Word download either installs
soffice (~500 MB) or loses that export. This PR adds a pure-JS
alternative: render the HTML via the existing exporthtml pipeline, then
feed it to the `html-to-docx` library in-process to produce a valid
.docx buffer — no soffice required, no subprocess spawn, no temp file
dance for the DOCX case.
Behavior:
- `settings.nativeDocxExport` (default `false`) gates the new path so
existing deployments see zero behavior change.
- When enabled, `type === 'docx'` requests skip the LibreOffice branch,
run `html-to-docx(html)`, and return the buffer with the
`application/vnd.openxmlformats-officedocument.wordprocessingml.document`
content-type.
- If the native converter throws, the handler falls through to the
existing LibreOffice path — so flipping the flag on is safe even on a
mixed-installation where soffice is still present as a backstop.
- Other export formats (pdf, odt, rtf, txt, html, etherpad) are
unchanged.
Files:
- `src/package.json`: `html-to-docx` dep (pure JS, no binary reqs)
- `src/node/handler/ExportHandler.ts`: new DOCX branch gated on the
setting, with fall-through on error
- `src/node/utils/Settings.ts`, `settings.json.template`,
`settings.json.docker`, `doc/docker.md`: wire up the new setting +
env var (`NATIVE_DOCX_EXPORT`)
- `src/tests/backend/specs/export.ts`: two new tests — asserts the
exported buffer is a valid ZIP (PK\x03\x04 signature) and the
response carries the correct content-type — both with
`settings.soffice = 'false'` to prove the path doesn't need soffice
at all.
Out of scope for this PR:
- Native PDF export (would need a PDF rendering step — separate
undertaking, and the issue acknowledges the `pdfkit`/puppeteer size
trade-off).
Closes#7538
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(7538): skip native DOCX test when html-to-docx isn't installed
The upgrade-from-latest-release CI job installs deps from the previous
release's package.json (before this PR adds html-to-docx) and then
git-checkouts this branch's code without re-running pnpm install.
Under that one workflow the new test can't find the module and fails
on the LibreOffice fallback, masking that the native path actually
works in every normal install.
Guard the describe block with require.resolve('html-to-docx'); Mocha's
this.skip() on before cascades to the sibling its. Regular backend
tests (pnpm install against this branch's lockfile) still exercise it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(7538): spec for soffice-free DOCX/PDF export and DOCX import
Captures the agreed scope expansion of PR #7568: replace the flag-gated
native DOCX path with a soffice-first selection cascade, add native PDF
export via pdfkit + a small htmlparser2-driven walker, and add native
DOCX import via mammoth. Also defines a shared HTML sanitizer
(stripRemoteImages) used by both export converters to close the
SSRF surface that Qodo flagged on the html-to-docx path.
The spec drops the nativeDocxExport setting and its env var; with
soffice configured, behavior is unchanged, and with soffice null,
docx/pdf export and docx import all work in-process. odt/doc/rtf
(and pdf import) keep needing soffice and are documented as such.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(7538): implementation plan for native DOCX/PDF and DOCX import
Bite-sized TDD task breakdown of the soffice-free export/import work:
rebase, deps, sanitizer, PDF walker, mammoth wrapper, ExportHandler
cascade, route guard, ImportHandler branch, UI fix, flag rollback,
verification + Qodo reply.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(7538): add pdfkit, htmlparser2, mammoth deps
Pure-JS, no native binaries:
- pdfkit ^0.18.0 (PDF rendering)
- htmlparser2 ^12 (SAX parser used by walker + sanitizer)
- mammoth ^1.12 (DOCX -> HTML for native import)
- @types/pdfkit ^0.17 (dev)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(7538): add stripRemoteImages HTML sanitizer
Drops <img src=> elements pointing at non-data, non-relative URLs to
prevent the DOCX/PDF converters from making outbound requests via
plugin-modified HTML. Closes Qodo finding #4 against the
html-to-docx path; will be wired into both export branches in
the cascade refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(7538): native PDF export via pdfkit + htmlparser2 walker
Renders pad HTML to a PDF Buffer in-process: headings, paragraphs,
lists, links, inline emphasis, data:-URI images. Remote images are
explicitly skipped at the walker (defense-in-depth on top of the
shared stripRemoteImages sanitizer).
PDFs are emitted with compress:false so accessibility/SEO indexers
that don't FlateDecode can still extract text. Pads are small enough
that the size cost is negligible.
Walker is 167 LOC, well under the spec's 500-LOC bail-out
threshold for switching to pdfmake+html-to-pdfmake+jsdom.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(7538): native DOCX import via mammoth
Wraps mammoth.convertToHtml so a soffice-less Etherpad can ingest
.docx files. Images are coerced to data: URIs at the converter
boundary so the import pipeline never sees a remote src=.
Includes a tiny generated DOCX fixture (heading, paragraph, list)
under tests/backend/specs/fixtures/ for both this wrapper test and
the upcoming end-to-end import test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(7538): soffice-first cascade in ExportHandler
Replaces the flag-gated DOCX branch with a deterministic dispatch:
soffice if configured, native DOCX/PDF otherwise, 5xx on native
error. Both native paths run plugin-modified HTML through
stripRemoteImages first.
Test changes:
- existing native DOCX block now sets soffice=null (was 'false', a
truthy non-null string that sidestepped the route guard); fixes
Qodo finding #3.
- new native PDF integration tests assert %PDF- header and
application/pdf content-type with soffice=null.
- new negative test: with soffice=null, /export/odt still returns
the 'not enabled' message.
- the legacy 500-on-export-error test now uses /bin/false so it
exercises the soffice error path explicitly (the cascade dropped
the ad-hoc 'false' string; .doc has no native path so this still
works as a soffice error probe).
Integration tests for native DOCX/PDF currently fail because the
/export route guard still treats both formats as soffice-only;
the next commit fixes that.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(7538): allow docx/pdf through export guard without soffice
Tightens the no-soffice block to ['odt','doc'] only — formats with
no native path. docx and pdf are handed to ExportHandler, which
dispatches to the in-process converters. Closes Qodo finding #2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(7538): native DOCX import path in ImportHandler
When soffice is null and the upload is .docx, run mammoth and feed
the resulting HTML through setPadHTML. Other office formats
(pdf/odt/doc/rtf) are explicitly rejected with uploadFailed instead
of silently falling through to the ASCII-only path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(7538): always show DOCX/PDF export links
Native paths (#7538) make DOCX and PDF available regardless of
soffice presence, so unconditionally render those links. ODT still
gates on exportAvailable. Closes Qodo finding #2 on the UI side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(7538): drop nativeDocxExport flag
Selection is now purely soffice-presence-driven (cascade in
ExportHandler). The opt-in setting and its NATIVE_DOCX_EXPORT env
var are no longer needed -- soffice configured means soffice path;
soffice null means native path (DOCX, PDF, and DOCX import).
Reverts the additive surface introduced earlier in this PR.
Also updates the SOFFICE doc row to reflect that null no longer
means 'plain text and HTML only' -- docx/pdf export and docx
import now work natively without soffice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(7538): tighten link annotation assertion
CodeQL flagged the loose 'raw.includes("etherpad.org")' as
'incomplete URL substring sanitization' (a false positive in test
context, but worth fixing). Match the full /URI (host) form
instead -- it's both more accurate (we're verifying the PDF link
annotation structure) and CodeQL-clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(7538): cleaner DOCX/PDF output + round-trip test coverage
DOCX:
- New extractBody helper drops <head>/<style> and the leading
newline inside <body> so html-to-docx doesn't render CSS or
prefix paragraphs with empty space.
- New wrapLooseLines pre-processor wraps loose pad lines in <p>
before the converter sees them. html-to-docx renders <br>
outside <p> as a new <w:p> (full empty line in Word); inside
<p> it correctly emits <w:br/> (soft break). Etherpad's HTML
uses bare <br> for every line, so this was making single
Enters look like double Enters in the Word output.
PDF:
- Walker SKIP_TAGS rejects head/style/script/title/meta/link
content -- prior version dumped CSS into the rendered PDF.
- New breakLine() helper combines flushLine() with moveDown(1).
pdfkit's text('', false) closes the continued run but does
NOT advance the cursor, so consecutive runs were stacking at
the same y-coordinate. <br>, end-of-block, and list items
now use breakLine().
- ontext collapses runs of whitespace and drops pure-whitespace
text nodes so pretty-printed source HTML doesn't render its
formatting newlines.
Round-trip:
- New backend test: pad text -> DOCX export -> DOCX import ->
new pad. Asserts content survives the trip.
- New PDF sanity test: extracts visible text from the PDF stream
and asserts the source pad text appears verbatim.
- 6 new unit tests for extractBody and wrapLooseLines plus 1 for
PDF walker SKIP_TAGS coverage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(7538): CodeQL ReDoS + import.ts type error
- BR_PARA_RE was /(?:\s*<br>\s*){2,}/ -- two adjacent \s* runs can
match the same chars, so on '<br>\t<br>\t<br>...' the regex
backtracks exponentially. Re-anchored to match a fixed first <br>
followed by one or more additional <br>s, so each whitespace run
has exactly one home.
- import.ts: fetchBuffer was typed Promise<Buffer> but call sites
chained .expect(200) on it, which only works on supertest's Test
object. Return the Test (typed any) so the chain is preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(7538): plugin-aware HTML cleanup, PDF text-align, monospace
ep_headings2/ep_align emit one heading-styled blank-line block after
every styled line in the pad ('<h1 style=text-align:right></h1>'),
which both html-to-docx and our pdfkit walker render as a full empty
paragraph. Plus the pdfkit walker had no support for text-align or
monospace, so right/center alignment and 'code' lines rendered the
same as plain body text.
- New dropEmptyBlocks helper strips empty h1-h6/p/code/pre/div/
blockquote wrappers in preprocessing. Iterates so nested empties
collapse too. Applied before both DOCX and PDF conversion.
- PDF walker now reads style='text-align:left|center|right|justify'
on block elements (h1-6, p, div) and passes it as pdfkit's align
option. align is applied once per continued run, then reset on
flushLine so the next block can pick up its own value.
- PDF walker handles <code>, <tt>, <kbd>, <samp> as inline monospace
(Courier) and <pre> as block monospace (Courier + breakLine on
open/close).
11 new unit tests:
- 4 for dropEmptyBlocks (heading wrappers, code, nesting,
pass-through)
- 1 for PDF text-align (compares the BT matrix x for left vs right)
- 2 for Courier in <code> and <pre>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(7538): separate adjacent heading-style blocks on import
Round-trip bug: ep_headings2 emits <h1>/<h2>/<code> in pad HTML.
Mammoth round-trips them as adjacent <h1>A</h1><h2>B</h2><p>C</p>
on import. Etherpad's server-side content collector has a default
_blockElems set of just {div, p, pre, li}, and ep_headings2 only
registers the CLIENT-side aceRegisterBlockElements -- not the
server-side ccRegisterBlockElements. So h1/h2/code end up being
treated as inline by the importer, and adjacent blocks merge into
a single pad line.
Fix: insert <br> after </h1>...</h6>/</code> when followed by
another block. Server-side workaround keeps this PR self-contained
regardless of plugin version. The right long-term fix is to extend
ep_plugin_helpers' lineAttribute factory to register both hooks
(filed as a follow-up).
Tests:
- 5 unit tests for separateAdjacentHeadingBlocks
- New end-to-end round-trip test asserts H1+H2+P land on three
separate pad lines after the import path.
Plus the prior PDF text-align/Courier/code commit also included
here:
- code/tt/kbd/samp inherit text-align from style attribute
- pre inherits text-align too
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(7538): blank-line round-trip + DOCX code monospace + a==c tests
DOCX round-trip dropping blank pad lines:
- wrapLooseLines now emits an explicit <p></p> marker for each blank
line in a <br> run, instead of collapsing all gaps into a single
paragraph break. (N consecutive <br>s -> 1 paragraph boundary +
N-2 empty <p></p> markers, mapping to N-1 blank pad lines.)
- mammoth's docxBufferToHtml now passes ignoreEmptyParagraphs:false
so the empty <w:p> entries survive the import side. mammoth's
default of true was silently dropping them.
- dropEmptyBlocks no longer strips <p></p> -- that's the meaningful
marker for the round-trip. Empty <h1>/<code>/<pre>/<div>/
<blockquote> are still stripped (plugin noise).
DOCX <code> rendering as monospace:
- New applyMonospaceToCode wraps code/tt/kbd/samp/pre content in a
<span style="font-family:'Courier New', monospace">. html-to-docx
honors that and emits <w:rFonts w:ascii="Courier New".../>, which
Word renders as Courier. The bare <code> tag is otherwise just
a no-op for html-to-docx.
- Applied only on the DOCX export path (PDF walker already handles
monospace via Courier font selection).
Round-trip tests:
- New a==c suite: txt, etherpad, html, docx -- export from src,
import to dst, re-export and compare against the meaningful
invariant (line text for binary formats; trimmed body for HTML).
- HTML test tolerates one trailing <br> per round-trip because
setPadHTML appends a final <p> on import; this is pre-existing
core behavior, not our bug.
- DOCX test normalizes trailing newline run (same reason).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(7538): preserve <w:jc> alignment through mammoth round-trip
mammoth doesn't expose Word's paragraph alignment (`<w:jc>`) when it
converts a docx to HTML -- there's no equivalent in its style-mapping
machinery. To keep alignment through DOCX round-trips we walk the
docx's document.xml directly, pull the `w:val` from each `<w:p>`'s
`<w:jc>`, and inject `style="text-align:..."` onto the matching
block element in mammoth's output by document order.
Word's w:jc accepts more values than CSS text-align; we map left/
start, center, right/end, both/justify/distribute and skip the rest
(start/end take left/right because we don't track ltr/rtl from the
docx for now).
Combines with the upstream ep_align PR (ether/ep_align#183) for the
full round-trip: this PR makes the mammoth output carry the
alignment style; ep_align#183 makes the importer pick it up.
Closes the alignment side of #7538.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(7538): preserve <a href> inside <code>/<pre> in DOCX export
html-to-docx silently drops <a href> children of <code>/<pre> tags
(and of styled <span>s, but the <code> wrapper is the active offender
here). The pad-export HTML produced by ep_headings2 + ep_align uses
<code style='text-align:right'>...<a href>...</a>...</code> for each
'Code'-style line, which lost its links on every DOCX export.
Workaround: applyMonospaceToCode now drops the code/pre/tt/kbd/samp
wrapper entirely. The non-anchor content gets wrapped in monospace
spans; anchors are emitted unstyled so they keep their hyperlink. For
block-level usage (<pre>, or <code> with an inline style attr) we
emit a wrapping <p> and forward the text-align style. Run BEFORE
wrapLooseLines so the <p> doesn't get double-wrapped.
Tests added:
- inline <code> -> just a styled span (no <code> wrapper)
- <code style='text-align:right'> -> <p style> wrap
- <pre> -> always block-wrapped
- <tt>/<kbd>/<samp> -> inline span only
- regression: <a href> inside <code> survives html-to-docx round-trip
with both the URL in word/_rels/document.xml.rels AND a <w:hyperlink>
in the document body
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(7538): HTML import line-break drift between blocks
Etherpad's HTML export wraps each pad line in <p>...</p> (or <h1>,
<code>, etc.) and then appends a <br> between lines. The closing
block tag already ends the line for contentcollector, so the trailing
<br> is redundant -- and on import the server collector counts BOTH
as line breaks, doubling every blank line between paragraphs and
inserting an extra blank between adjacent headings.
Two fixes, both gated on the runtime block-element registry so they
don't double-trigger when the underlying plugin already handles
adjacency:
1. HTML import path now runs the new collapseRedundantBrAfterBlocks
helper before setPadHTML. Drops a single <br> immediately
following </p>/</h1-6>/</code>/</pre>/</div>/</blockquote>/</ul>/
</ol>/</li>/</table>/</tr>/</td>/</th>. Multiple consecutive
<br>s after a block keep all but the first (the rest still
represent intentional blank lines).
2. The DOCX-import separateAdjacentHeadingBlocks workaround now
checks whether 'h1' is in the runtime ccRegisterBlockElements
set before inserting <br>s. When ep_headings2 has the new server
hook (per ep_plugin_helpers#14 + the upcoming ep_headings2 PR),
the workaround correctly stays out of the way -- otherwise it
adds an extra blank line per heading transition.
Also fixed a subtle ts-check failure on the import.ts test changes
and a leftover implicit-any in ImportDocxNative's alignment
preserver.
Tests added:
- collapseRedundantBrAfterBlocks: 5 unit tests (each block tag,
whitespace tolerance, multiple <br> keeping intentional blanks)
- HTML import: 'does not introduce a blank line between H1 and H2',
'preserves blank-line count between H1 and H2 (realistic shape)'
reproduces the 5-blanks-where-2-expected bug from the user's
round-trip pad.
1054 backend tests pass locally (the 6 failures are the pre-existing
favicon/webaccess send@1.x dotfile-path issue from running under
.claude/, doesn't reach CI).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(7538): skip plugin-dependent HTML import tests on no-plugin CI
The two new HTML-import-adjacency tests assume ep_headings2 (or
another plugin) has registered h1/h2 as server-side block elements
via ccRegisterBlockElements. Without that, contentcollector treats
<h1>/<h2> as inline and adjacent ones merge into a single pad line
-- making the assertions inapplicable.
CI's backend-tests job runs without plugins installed, so guard
the describe block with a runtime hooks.callAll() check and skip
when h1 isn't a registered block. Local dev with ep_headings2 (and
the local plugin patch wiring ccRegisterBlockElements) still
exercises both tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(docker): clear most CVEs in published image — npm/pnpm/uuid + drop curl
Cuts published-image vulnerabilities from 18 (4H/13M/1L) across 8 packages
to 12 (2H/9M/1L) across 3 packages. The remaining three (curl/libcurl,
git, busybox) are all upstream Alpine 3.23 packages with "not fixed"
status — libcurl is pulled in transitively by git and cannot be
removed independently.
Changes:
- Provision pnpm via corepack instead of `npm install -g pnpm`, then
remove the bundled npm. The base image's npm@10.9.7 ships old
transitives (picomatch 4.0.3 → CVE-2026-33671/33672, brace-expansion
2.0.2 → CVE-2026-33750) that we don't otherwise need at runtime;
corepack handles pnpm directly without npm. Fixes 1H + 1M.
- Bump PnpmVersion 10.28.2 → 10.33.2 to align with the rest of the
workflow and pull in pnpm's patched bundled brace-expansion (5.0.5
vs 5.0.4). Fixes 1M.
- Add `uuid@<14.0.0` → `>=14.0.0` to pnpm.overrides
(GHSA-w5hq-g745-h8pq). Fixes 1M.
- Drop `curl` from the runtime apk add list and switch HEALTHCHECK to
wget (busybox built-in). curl was only invoked by the healthcheck and
by dev/CI scripts that don't run in the container. Removes the curl
CLI binary; libcurl remains as a git transitive dep, so the
`apk/alpine/curl` advisories scout reports against libcurl persist
but aren't reachable from any code we ship. As a side-effect this
also clears nghttp2 (CVE-2026-27135) which was a curl-CLI dep.
- Switch HEALTHCHECK URL from `localhost` to `127.0.0.1` — alpine/musl
resolves localhost to ::1 first and Etherpad only binds IPv4.
Verified locally: docker build → docker run → healthy → docker scout
cves shows 12 CVEs / 3 packages.
* fix(docker): refresh corepack before preparing pnpm (Qodo)
Node 22's bundled corepack ships a stale signing-key list and can reject
newer pnpm releases (nodejs/corepack#612), which would fail the image
build at `corepack prepare`. Mirror the snap/snapcraft.yaml workaround:
`npm install -g corepack@latest` before activating pnpm, in both
adminbuild and build stages. npm is still removed afterwards.
* docs(changelog): note docker image dropping curl/npm/npx (Qodo)
Address Qodo's "backwards-incompatible change without mitigation" rule
violations by documenting the removal in the 2.7.3 breaking-changes
section. Operators who exec into the container can apk add curl on
demand or use the busybox wget / pnpm already present.
* chore: pnpm
* chore: pnpm
* chore: pnpm
* chore: pnpm
* chore: pnpm
* chore: pnpm
* chore: pnpm
* chore: pnpm
* chore: pnpm
---------
Co-authored-by: SamTV12345 <40429738+samtv12345@users.noreply.github.com>
VitePress 2.0 alpha refuses to build when the `vite` override resolves
to rolldown-vite unless `oxc-minify` is installed, which broke the
"Deploy Docs to GitHub Pages" workflow on develop:
`oxc-minify` is not installed. vitepress requires `oxc-minify`
to be installed when rolldown-vite is used.
Add it as a dev dependency of the doc workspace.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(admin): restore i18n on /admin by copying locales to the right path
The admin SPA fetches `/admin/locales/<lang>.json`. Building with
vite-plugin-static-copy and `src: '../src/locales'` was placing the
115 core locale files at `src/templates/admin/src/locales/` (the
plugin's `dirClean` strips a leading `../` but keeps the remaining
parent path). The express admin handler 404'd those fetches, fell
back to serving `index.html`, JSON.parse silently failed, and every
`<Trans>` rendered its raw key — see #7586.
Replace the plugin with a small inline build/dev plugin: at build
time copy `src/locales/*.json` to `<outDir>/locales/`; in dev serve
the same files via middleware so `vite dev` also works. Drop the
now-unused `vite-plugin-static-copy` dependency.
Add regression coverage that none of the existing admin specs had:
- backend HTTP test for GET /admin/locales/{en,de}.json
- Playwright admin i18n spec asserting translated <h1> renders for
the default locale and for ?lng=de, plus a request-level check
that the response is JSON, not the SPA fallback.
Closes#7586
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(admin): bundle locales via import.meta.glob, drop copy plugin
The first pass at #7586 replaced vite-plugin-static-copy with a
custom build/dev plugin that copied src/locales/*.json into the
admin output and served them in dev. That works, but the
vite-plugin-static-copy README explicitly recommends the public
directory or a JS import for this case, and the import path is
strictly cleaner: no copy step, no /admin/locales/* express route,
no SPA-fallback-shaped failure mode.
Use import.meta.glob in admin/src/localization/i18n.ts so each
language ships as its own hashed JSON chunk and is lazy-loaded on
demand. The vite config goes back to just react + base + outDir.
The plugin namespaces (e.g. ep_admin_pads) keep their existing
admin/public/<ns>/<lang>.json layout.
Tests:
- Drop tests/backend/specs/adminLocales.ts — it asserted on a
/admin/locales/<lang>.json route that this approach no longer
uses; the regression mechanism it pinned doesn't exist anymore
and the test required the admin frontend to be built before the
backend test runs (which CI doesn't do).
- Keep tests/frontend-new/admin-spec/admini18n.spec.ts (rendered
<h1> in default and ?lng=de). Verified red→green: reverting just
the loader to the pre-fix /admin/locales fetch makes both specs
fail; restoring makes them pass.
Also update pnpm-lock.yaml to drop the now-unused
vite-plugin-static-copy entries — fixes ERR_PNPM_OUTDATED_LOCKFILE
that was failing every CI install upfront.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: design spec for issue #7570 (ueberdb2 driver bundling)
Spec for the upstream ueberDB fix (move 10 drivers back from optional
peer deps to dependencies) plus downstream etherpad-lite safety net
(explicit driver list + build-test-db-drivers CI job covering all 10
via presence check and MySQL+Postgres smoke tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: implementation plan for issue #7570 ueberdb2 driver bundling
Covers upstream ueberDB PR (move drivers from optional peer deps back
to dependencies, publish 5.0.46) and downstream etherpad-lite PR
(bump ueberdb2, defensive driver list, build-test-db-drivers CI job
with presence + MySQL + Postgres stages gating publish).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(#7570): bundle DB drivers, add regression CI
- Bump ueberdb2 to ^5.0.47 (upstream ueberDB PR #939 re-bundles drivers
as real dependencies instead of optional peer deps, fixing the class
of Docker-prod "Cannot find module" failures).
- Declare all 10 ueberdb2 DB drivers as direct src dependencies as a
defensive safety net against a future upstream drift.
- Add build-test-db-drivers CI job that blocks the publish job:
* all-10-drivers presence check in the built prod image
* end-to-end MySQL smoke (reproduces the #7570 repro)
* end-to-end Postgres smoke
Any stage failure blocks Docker Hub / GHCR publish.
Supersedes #7571.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ci): run driver presence test from src/ so node_modules resolves
The presence test ran node from the default cwd (/opt/etherpad-lite),
but the drivers are installed under /opt/etherpad-lite/src/node_modules
by the monorepo workspace. Adding `-w /opt/etherpad-lite/src` makes
Node resolve modules from src/node_modules where pnpm places them.
Matches how the production container itself runs: `pnpm run prod` is
invoked from src/ (cross-env + node --require tsx/cjs node/server.ts).
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a pnpm.overrides block to force-upgrade vulnerable transitive
dependencies to their patched versions. All 33 open Dependabot alerts on
ether/etherpad are against pnpm-lock.yaml; none of these packages are
direct dependencies of the workspace.
Bumps (vulnerable range → pinned):
- basic-ftp ≤5.2.2 → ≥5.3.0 (GHSA-5rq4-664w-9x2c,
GHSA-6v7q-wjvx-w8wg,
GHSA-rp42-5vxx-qpwr)
- brace-expansion <2.0.3 → ≥2.0.3 (GHSA-f886-m6hf-6m8v)
- diff <8.0.3 → ≥8.0.3 (GHSA-73rr-hh4g-fpgx)
- flatted <3.4.2 → ≥3.4.2 (GHSA-25h7-pfq9-p65f,
GHSA-rf6f-7fwh-wjgh)
- follow-redirects ≤1.15.11 → ≥1.16.0 (GHSA-r4q5-vmmm-2653)
- glob (10.x CLI) <10.5.0 → ≥10.5.0 (GHSA-5j98-mcp5-4vw2)
- js-yaml <4.1.1 → ≥4.1.1 (GHSA-mh29-5h37-fv8m)
- lodash ≤4.17.23 → ≥4.18.0 (GHSA-f23m-r3pf-42rh,
GHSA-r5fr-rjxr-66jc)
- minimatch (9.x) <9.0.7 → ≥9.0.7 (GHSA-23c5-xmqv-rm74,
GHSA-3ppc-4f35-3m26,
GHSA-7r86-cg39-jmmj)
- path-to-regexp (8.x) <8.4.0 → ≥8.4.0 (GHSA-27v5-c462-wpq7,
GHSA-j3q9-mxjg-w52f)
- picomatch (4.x) <4.0.4 → ≥4.0.4 (GHSA-3v7f-55p6-f55p,
GHSA-c2c7-rcm5-vvqj)
- qs <6.14.2 → ≥6.14.2 (GHSA-6rw7-vpxm-498p,
GHSA-w7fw-mjwx-w883)
- serialize-javascript ≤7.0.2 → ≥7.0.5 (GHSA-5c6j-r48x-rmvq,
GHSA-qj8w-gfj5-8c6v)
- socket.io-parser <4.2.6 → ≥4.2.6 (GHSA-677m-j7p3-52f9)
- tar <7.5.11 → ≥7.5.11 (GHSA-8qq5-rm4j-mr97,
GHSA-34x7-hfp2-rc4v,
GHSA-r6q2-hw4h-h46w,
GHSA-83g3-92jg-28cx,
GHSA-qffp-2rhf-9h96,
GHSA-9ppj-qmqm-q256)
- vite (non-aliased) <7.3.2 → ≥7.3.2 (GHSA-p9ff-h696-f583,
GHSA-v2wj-q39q-566r,
GHSA-4w7w-66w2-5vf9)
Scoped overrides are used where the vulnerable range is a specific major
line — e.g. `minimatch@>=9.0.0 <9.0.7` — so that 3.x/10.x lines resolving
via unrelated dependency chains are not disturbed. Otherwise the override
targets the bare package name.
Note: admin/ui/doc packages alias `vite` to `rolldown-vite@7.2.10`; those
are a separate package on npm and the vite CVEs do not apply to them.
- `pnpm install` succeeds
- `pnpm run ts-check` clean
- No source code changes; `tar` and `glob` are not directly imported by
etherpad-lite sources, so the major-version bumps (tar 6→7, glob 10→13)
affect only transitive consumers that already declare compatibility.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Bump ueberdb2 to ^5.0.42 — adds production dependencies for db drivers
5.0.42 moves core database drivers (dirty-ts, rusty-store-kv) to
production dependencies so they're installed in production/Docker.
Optional drivers (cassandra, mongodb, etc.) are now optional
peerDependencies.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Update to ueberdb2 ^5.0.43 — fixes Node engine requirement
5.0.43 relaxes the Node engine from >=22.22.0 to >=18.0.0,
matching etherpad-lite's supported Node versions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
5.0.41 includes the rolldown runtime fix (ether/ueberDB#925) needed
for the lazy-loaded database drivers to work at runtime.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ueberdb2 5.0.40 lazy-loads database drivers so only the configured
backend's dependencies need to be installed. Fixes the Docker production
build crash: "Cannot find module 'cassandra-driver'"
See ether/ueberDB#924
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>