Skip to content

fix bullets position in rtl mode - #1892

Merged
NGPixel merged 1 commit into
requarks:masterfrom
sesajad:patch-3
May 15, 2020
Merged

NGPixel merged 1 commit into
requarks:masterfrom
sesajad:patch-3

Conversation

@sesajad

@sesajad sesajad commented May 15, 2020

Copy link
Copy Markdown
Contributor

Bullets (I don't know, list markers? whatever) are at the left edge in both RTL and LTR mode and this line fixes it.

@auto-assign
auto-assign Bot requested a review from NGPixel May 15, 2020 11:57
@NGPixel
NGPixel merged commit 1efdd6d into requarks:master May 15, 2020
jionggyu pushed a commit to jionggyu/wiki-2.5.302-patch that referenced this pull request Jul 9, 2024
dylan-hart added a commit to dylan-hart/wiki that referenced this pull request Aug 31, 2026
* Format winput-autofocus-mechanism.md per oxfmt (asterisk->underscore italics)

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* Stub contentSync.forgetContent in deleteAsset hook-ordering test

Merging wp-1673's contentSync cleanup into deleteAsset left the existing
hooks/storage ordering test's WIKI stub without a contentSync model,
throwing on the newly-added forgetContent call.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* Merge wp-1684-userkeys-token-index-purge: resolve cron seed conflicts, oxfmt fix

Combines purgeContentSyncState and purgeUserKeys as separate seeded cron
entries (offset to 40/45 past midnight to avoid a same-minute clash), keeps
both branches' JOB_SCHEDULE_SEED tests, and merges the drizzle-orm import
lists in models/users.ts.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Fix double glossary-cache invalidation from merging wp-1688

The merge conflict resolution for wp-1688-extract-move-side-effects-helper
initially kept HEAD's direct WIKI.models.glossary.invalidateCache() call
inside recordPageMoveSideEffects, alongside the helper's own batched
glossaryInvalidate-flag return value that the caller (movePage) already
ORs across the whole move batch and invalidates once. That double-counted
the cache invalidation for a single-page move. Removed the direct call so
only the flag-based, once-per-batch invalidation remains, matching the
helper's own docstring and the WP's regression test.

* Give adminStore's fetch actions an error path (#1732)

None of adminStore's four fetch actions (fetchLocales, fetchInfo,
fetchSites, fetchClassificationLevels) had a try/catch, so a rejection
propagated out unhandled -- most visibly, AdminLayout.vue's onMounted
awaits fetchSites() bare, so a 401 on an expired session or a 5xx
skipped the following fetchInfo() call entirely, leaving the dashboard
silently half-initialised with no notification explaining why.

Wrap each action's body in a try/catch that notifies and leaves the
slice at its state() default. This also fixes AdminLayout.vue's mount
sequence for free: fetchSites() can no longer reject, so execution
reaches fetchInfo() regardless, and the two un-awaited calls
(fetchLocales, fetchClassificationLevels) can no longer produce an
unhandled promise rejection either -- no AdminLayout.vue change
needed.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Flip boot/api.js and AuthLoginPanel's ky clients to throwHttpErrors: true

boot/api.js's shared ky client used `throwHttpErrors: (statusNumber) =>
statusNumber > 400`, so a 400 resolved instead of rejecting and callers had
to remember to check `resp.ok`. Flip it to plain `true` so a 400 is an
error like any other -- every catch already routes through
apiErrorMessage(), which reads err.data.message off the same envelope, and
ky's HTTPError natively populates .data with the parsed body before
throwing.

AuthLoginPanel.vue's login/register/changePwd/resetPassword each repeated
the same ad-hoc override on their own API_CLIENT calls; remove all four so
they inherit the client's new default rather than keeping the old
non-throwing behaviour.

Adds boot/api.test.js (new file) driving the real ky client against a
stubbed fetch to assert a 400 rejects with an HTTPError carrying
data.message, and a 2xx/500 sanity pair. Updates two AuthLoginPanel.test.js
assertions that expected the now-removed per-call throwHttpErrors option.

WP #1758, part of epic #1754. This is the enabling change; it lands before
the sibling WPs that convert unwrap() and the sites that still branch on a
resolved { ok: false } (#1762, #1767, #1772, #1776) -- until those land, a
handful of other call sites (PageHistoryOverlay's branchFrom,
AdminPagesDeleted's pageInvalidLocale, SiteActivateDialog/SiteDeleteDialog)
will throw on a 400 instead of resolving with ok:false, which is expected
per the epic's breakdown.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Rename the five delegating local date formatters to shared humanizeDate

Search.vue, TagsBrowse.vue, AdminUsers.vue, AdminComments.vue and
AdminPagesDeleted.vue each declared a local `humanizeDate`/`formattedDate`
wrapper that just delegated to `userStore.formatDateTime`. Replace all five
with the shared `humanizeDate` import from helpers/datetime.js so one grep
finds every absolute-timestamp formatter in the app. No behavior change;
drops now-unused `useUserStore` imports where nothing else in the file
referenced it.

WP #1759

* Fix blockAllowances() doc: setBlocksState does not queue a re-render (#1738)

The doc comment claimed disabling a block "take[s] effect on the pages
that already embed it, since each is re-rendered through here" -- false.
setBlocksState() only flips isEnabled/config; nothing queues a re-render
of pages carrying the tag, so a page saved before the toggle keeps
<block-x> in its stored render until it is next saved or explicitly
re-rendered.

The reader-facing exposure this implied (a disabled block's component
still loading for readers) is already closed by #1729's blocksIndex
guard, landed separately -- so this is a pure doc/behavior reconciliation,
not a new gap. Building a bounded bulk re-render queue was considered and
deliberately left out of scope: there's no cheap way today to find "pages
whose stored render embeds tag X", and setBlocksState has no PageActor
to compute per-page render permissions from.

Corrects the doc on both sides (blockAllowances() and setBlocksState)
and adds a DB-backed regression test locking in the actual behavior --
disabling a block leaves pageRenderQueue empty and a page's stored
render untouched -- so neither doc can silently drift out of sync again.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Delete stores/page.js#unwrap() and convert callers to try/catch (#1762)

unwrap() existed only to turn boot/api.js's non-throwing 400 contract
back into a rejection. pageMove/pageRename/pageSave now let the real
API rejection propagate and convert it via apiErrorMessage() so the
server's message still lands on the thrown error's .message, which is
what callers such as PageHeader.vue and PageActionsCol.vue already
read directly. pageSave's outer catch distinguishes a genuine ky
HTTPError (via .response) from its own ERR_*/ERR_SAVE_CONFLICT errors,
which continue to pass through unconverted.

Adds refusal-path coverage for pageMove() and pageRename() (neither
had any) plus a non-409 refusal case for pageSave()'s create and
update paths.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Hoist the saveConflict watcher into PageHeader.vue's save handler (#1747)

editorStore.saveConflict's watcher and resolveSaveConflict() lived only in
EditorMarkdown.vue, so PageSaveConflictDialog.vue was reachable from the
Markdown editor alone -- EditorWysiwyg, EditorCode, EditorAsciidoc and
EditorRedirect all fell through to saveChangesCommit()'s generic negative
toast on a 409 instead. Move both into PageHeader.vue, the one save path
every editor already routes through, and suppress that generic toast when
the failure is ERR_SAVE_CONFLICT since the dialog is already on its way up.

The hoisted resolveSaveConflict() drops the Markdown-specific editor.setValue()
/processContent() calls its original "discard" branch made directly against
EditorMarkdown's local Monaco instance -- PageHeader has no reference to
whichever editor is mounted, so it patches pageStore/editorStore state only.
The page's stored content is corrected either way; that one editor's own
on-screen copy can lag a beat behind it until the next edit or a remount.

Add PageHeader.test.js coverage: the watcher raising the dialog with a
non-Markdown editor active, discard adopting the server snapshot, and the
generic toast being suppressed for ERR_SAVE_CONFLICT but not other failures.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Declare w-checkbox's dense prop, drop dead size attributes

WCheckbox.vue bound no size prop, so the 2 `size="..."` attributes at
its call sites rendered as no-op DOM attributes. Declares `dense`
(matching the boolean convention already used by WToggle and WInput),
shrinking the box and glyph, and updates the 2 size call sites to
agree: AdminAuth.vue's `size="sm"` becomes `dense` (its checkbox sits
in an already-dense w-select option row), and PageDataDialog.vue's
redundant `size="lg"` (contradicting its own `dense`) is dropped.

OpenProject #1806

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Serve content-hashed /_assets/ files as immutable (OpenProject #1821)

Add isHashedAssetFilename() to helpers/common.ts, a pure predicate for
vite's `[name]-[hash].[ext]` build output naming, and wire it into the
/_assets/ fastifyStatic registration's setHeaders callback so hashed
files get a far-future immutable Cache-Control header (matching
thumb.ts's THUMB_CACHE precedent) while the 8 unhashed entries keep
the existing 7-day maxAge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Remove the three call-site rerender workarounds and MCP blank-render caveats (OpenProject #1723)

models/pages.ts's createPage()/updatePage() already own the
ensureCanRender-before-write / queueRerender-after-write pairing for a
content-without-render write (#1716), so the callers that used to
compensate for its absence now just duplicate it:

- models/approvals.ts's approveSubmission() dropped its own
  `if (!render) queueRerender(...)` after updatePage().
- modules/storage/disk/storage.ts's importPage() dropped its
  try/catch queueRerender() after createPage() -- a missing Puppeteer
  now refuses the create up front instead of landing a blank page,
  surfacing as an `unrecognized` entry via importLocaleDir's existing
  per-entry try/catch.
- migration/page-import.ts's 'queue' renderBootstrap mode already left
  `input.render` undefined; its separate post-create queueRerender()
  call (and the now-dead MappedPage.queueRerender/PagesWriteModel.
  queueRerender plumbing) is gone too -- createPage() alone does the
  whole job.

Also found and removed a fourth, unlisted instance of the same bug:
models/pageHistory.ts's recoverDeletedPage(), which landed on
2026-08-30 (after this WP was filed) with an identical compensating
call. Left in, it would have failed this WP's own Done-when grep
("no compensating call outside models/pages.ts and the
scheduler/route paths").

Deleted the now-false "rendered view is blank until re-saved" caveats
from mcp/tools/createPage.ts and updatePage.ts.

Updated backend/migration/page-import.test.ts's FakePagesModel and
backend/modules/storage/disk/storage.test.ts's fakeImportDeps to
simulate createPage()'s internal auto-queue behavior (they stand in
for the real model), and rewrote backend/models/pageHistory.test.ts's
DB-backed coverage to assert the real pageRenderQueue row instead of
a mocked queueRerender call, and the up-front-refusal behavior instead
of the old best-effort one.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Standardise W* library on `disabled`, delete Quasar-era `disable` alias

WBtn, WBtnToggle, WCheckbox, WInput, WRadio, WSelect, WTab and WToggle each
accepted both `disable` and `disabled` for the same concept, with the
redundant `props.disable || props.disabled` OR baked into every one of
them. WRange and WRating only ever had `disable`. Standardise all ten on
`disabled` -- the native HTML spelling, and the one WItem already used --
and delete the alias outright per CLAUDE.md's no-shim policy.

Renames every `:disable="…"` call site (~75 across components/ and pages/)
plus two bare shorthand `disable` attributes to `disabled`, and rewrites
the WCheckbox/WInput/WSelect/WRange/ModuleConfigForm tests that asserted
the alias. WSelect's separate `optionDisable`/per-option `opt.disable`
data field is an unrelated concept and is left untouched.

Part of OpenProject #1799 (epic #1784).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* De-correlate updateNavigation's cascade NOT EXISTS into a CTE anti-join

The correlated NOT EXISTS in updateNavigation()'s cascade UPDATE
re-evaluated the concatenated-ltree ancestor expression once per
candidate row. Collect the override/hide boundary paths once into a
`boundaries` CTE and anti-join against it instead, so the expression
is evaluated per boundary rather than per row. Semantics are
unchanged; added a test for a boundary nested directly under another
boundary (OpenProject #1827).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Re-check native Temporal browser support (WP #1828)

Safari still lacks native Temporal support as of 2026-08-31 (only
behind a flag in Safari Technology Preview), while Chrome 144+,
Firefox 139+ and Edge 144+ now ship it natively. Updates
boot/temporal.js's header comment with the current dated position,
replacing the stale "mid-2026" claim. Go/no-go decision (GO) recorded
on parent WP #1824: siblings #1833 and #1838 proceed.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Add GiST expression index for navigation ancestor lookup (OpenProject #1823)

ancestorNavId's ltree containment query filters on ("folderPath" ||
"fileName"), which neither existing folderPath index (bare-column
btree/GiST) can match — Postgres was falling back to a row-by-row
filter over every override/hide candidate. EXPLAIN (ANALYZE, BUFFERS)
against a 280k-row seeded tree measured a ~10x execution-time drop
(1.71ms -> 0.18ms) and ~228x fewer buffer reads (1602 -> 7) once the
expression index lets the planner Bitmap-AND it with
tree_navigationMode_idx instead. Full before/after EXPLAIN output is
recorded on the work package.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Add a defineProps-vs-call-site attribute drift check for W* components (#1805)

Adds frontend/src/components/shared/wComponentAttributeDrift.test.js: parses
each shared W* component's defineProps (object or array form, including
components declaring none, like WScrollArea) into a per-tag prop registry,
scans every <w-*> call site's template region across frontend/src, and fails
on any bound or static attribute naming no declared prop -- allowing the
fall-through channel (class, style, key, ref, v-* directives, @/# handlers,
data-*/aria-*). Includes fixture-level unit tests of the parser mechanism
plus the real-tree gate itself.

Running it against the current tree (already rebased onto scarlett, so
#1789/#1796/#1803's landed sweeps are in) surfaced 20 residual violations the
earlier sweep didn't reach. Fixed each:
- Deleted genuinely dead attributes: NavSidebar.vue's w-scroll-area still
  bound :thumb-style/:bar-style to variables that no longer exist anywhere
  (the store getter they once read was already removed); a stray w-banner
  rounded in two more files (WBanner is unconditionally rounded already);
  w-checkbox dense/size on two more sites (WCheckbox has neither prop);
  w-input standout in GroupEditOverlay.vue (only WSelect declares it); a
  dead :tabindex on a w-chip inside a #selected-item slot template WSelect
  never actually renders.
- Added four small declared props for attributes that are functional today
  via implicit $attrs fallthrough, rather than deleting live behavior:
  WBtn gains title (native tooltip, HeaderSearch.vue/ProfileAuth.vue) and
  tabindex (AdminGeneral.vue's inert logo-preview button pairs tabindex="-1"
  with aria-hidden="true" so it isn't reachable by tab); WBadge gains title
  (ProfileAuth.vue's 2FA-active badge tooltip); WCardSection gains id
  (PagePropertiesDialog.vue scrolls each section into view by id).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Cache the generated navigation tree per site/menu/locale (#1825)

Cache generateFromTree's raw, pre-filter output under WIKI.cache
(nav:${siteId}:${navId}:${locale}) so a warm auto/mixed menu costs one
cache read instead of one query per folder level. Caching happens
before getNav's userGroups/unfiltered visibility pass, since the walk
itself is actor-blind (pageIsVisible(..., true) always) -- caching
anything after the merge/filter would leak a visibilityGroups item
between viewers.

Invalidation is site-wide rather than per-navId, since one tree write
can change what any ancestor menu's walk returns. Wired from
navigation.ts's own writes (setNavItems, updateNavigation,
deleteNavForEntries -- now siteId-scoped), tree.ts's structural writes
(createFolder, renameFolder, addPage, deleteEntry), and pages.ts's
updatePage when publishState/icon/title change.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Convert ok:false envelope guards to catch blocks (#1767)

Across ~65 files, remove `if (!resp?.ok) { throw ... }` / `resp?.ok === false`
checks that compensated for boot/api.js's pre-flip non-throwing 400 -- these
become dead code once the api client throws on every non-2xx status, and were
already silently discarding server error messages wherever the surrounding
catch fell back to `err.message` instead of `apiErrorMessage(err)`.

Every genuine HTTP-error-envelope guard around an API_CLIENT mutation call is
converted; catches that already read `apiErrorMessage(err)` just lose the now-
redundant guard, catches that read `err.message` gain `apiErrorMessage(err)`,
and per-error-code translations (`t('ns.' + resp.error, resp.message)`) move
to `err.data?.error`. AdminPagesDeleted.vue's pageInvalidLocale branch and
several `localizeError()` call sites keep their exact existing behavior.

Left untouched: AuthLoginPanel.vue's four ad-hoc ky clients (#1758's scope),
stores/page.js#unwrap() and its callers (#1762's scope), and genuine
business-logic `ok` fields unrelated to HTTP status (webhook connectivity
test results, ImportBatchPageDialog's client-tracked per-row status,
BlockUploadDialog's local file validation).

Updates the co-located tests whose mocks resolved `{ ok: false }` to instead
reject with an `err.data`-carrying Error, matching the established
apiErrorMessage() test convention, and adds regression coverage for the
three sites the work package named explicitly.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Fix trailing blank line left by merge conflict resolution (oxfmt)

* Lazy-load and size the two per-row avatar images (#1855)

Add loading="lazy" plus explicit width/height to the UserSearchDialog
and CollabPresence row avatar <img> elements, matching each avatar's
rendered box (32px / 30px). The three viewport-chrome avatars
(AccountMenu, HeaderActionsMenu, ProfileAvatar) are left unchanged so
lazy-loading doesn't delay them.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Feature-detect Temporal in index.html, preload polyfill chunk (WP #1838)

Adds an inline, non-module script to frontend/index.html that checks
typeof globalThis.Temporal === 'undefined' and, only then, injects a
<link rel="modulepreload"> for the temporal-polyfill chunk. This lets an
affected browser (Safari) start fetching the chunk in parallel with the
eager bundle instead of only discovering it after main.js's top-level
await initializeTemporal() runs, once the whole eager module graph has
already loaded.

The chunk's hashed URL is only known post-build, so the injected href is
the placeholder __TEMPORAL_POLYFILL_HREF__, documented as the contract
sibling WP #1833's Vite plugin substitutes at build time. main.js and
boot/temporal.js are unchanged -- this only warms the cache the existing
dynamic import then hits.

Also extends vitest.config.js's include glob so index.test.js (which
executes the real extracted script text in both browser conditions) is
discoverable outside src/.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Add Vite plugin to resolve the hashed temporal-polyfill chunk URL

frontend/src/build/temporalPolyfillChunk.js exports a pure
findTemporalPolyfillChunkFileName(bundle) that locates the built chunk
carrying temporal-polyfill's global.esm module (matched against the
package's exports["./global"] mapping, including the pnpm-nested and
/full variants), plus temporalPolyfillChunkPlugin() which substitutes
the chunk's real hashed URL into a new <!--temporal-polyfill-chunk-url-->
placeholder in index.html as an inline window.__wikiTemporalPolyfillUrl
assignment -- never an unconditional <link rel="modulepreload">, so
browsers with native Temporal pay nothing extra. The lookup throws
loudly rather than emitting an empty URL when no polyfill chunk is
present.

WP #1833, part of epic #1824 (Preload the Temporal polyfill in
parallel with the entry bundle for browsers that need it).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Answer conditional avatar/site-asset requests from the hash column (#1852)

controllers/user.ts and controllers/site.ts read only the sha1 hash column
first, build the ETag from it, and return 304 without ever loading the
blob when If-None-Match matches; the blob is read only on a miss. Adds
the hash-only readers (users.getAvatarHash, sites.getAssetHash) these
routes need -- #1849's remaining scope, added here since #1852 can't be
tested/implemented without them (only the hash column + write path from
#1846 had landed on origin/scarlett).

backend/controllers/user.test.ts (new) and backend/controllers/site.test.ts
assert a matching conditional request never calls the blob-loading model
method (getAvatar/getAsset), via mocked WIKI.models.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Add hash-only readers getAvatarHash/getAssetHash (#1849)

setAvatar/setAsset (#1846) already write the sha1 hash on every upload
and clearAvatar/clearAsset already delete the row outright, so the
remaining scope here is the two hash-only readers: getAvatarHash next
to getAvatar (models/users.ts) and getAssetHash next to getAsset
(models/sites.ts), each selecting only the hash column so a
conditional request never pulls the blob.

Tests: DB-backed round trips asserting the stored hash equals a sha1
of the bytes the blob reader returns, that a re-upload with different
bytes changes it, and that clearing leaves the hash reader returning
null again -- plus a pure-unit selection-shape test (WIKI.db.select
spy, following models/pages.test.ts's precedent) asserting each
reader's emitted selection has only a hash key, never data.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Memoise the pooled group-rule array in models/groups.ts (OpenProject #1858)

rulesForGroups() flatMapped rulesCache fresh on every call, and checkAccess/
checkSiteAccess/mayHoldPermissionSomewhere each call it at least once per
request. Memoise the pooled array in rulesPoolCache, keyed on the sorted
group-id set (rule order carries no meaning per helpers/pageRules.ts), and
clear it in reloadCache() -- which both broadcastReload() and the inbound
reloadGroups event handler call exclusively, so a rule change is visible on
the next checkAccess either way.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Cap listPagesForSitemap and paginate /sitemap.xml past 50,000 URLs

listPagesForSitemap selected every published, browsable page for a site
with no query limit, and the flat single-file sitemap it fed had no cap
either -- sitemaps.org rejects anything over 50,000 URLs. The model
query now carries a hard, generous ceiling (SITEMAP_QUERY_CAP) so the
read itself can never be unbounded, and /sitemap.xml switches to a
sitemap index over SITEMAP_URL_LIMIT-sized child sitemaps (addressed by
?page=N on the same route) once a site's page count exceeds the
sitemaps.org per-file cap, while staying byte-for-byte the same flat
output for sites under it.

OpenProject #1857

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Cache locale strings in WIKI.cache, serve ETag on GET /:code/strings (#1839)

getStrings() previously read the strings JSONB column from postgres on
every call, and the route set neither ETag nor Cache-Control despite
en.json alone being 2,807 keys / 180KB serialized on every response.

Caches the parsed strings under localeStrings:${code} in WIKI.cache,
mirroring the existing locales key pattern, invalidated from
refreshFromDisk, sideloadFromDataPath and reloadCache. The route now
sets an ETag derived from the locale row's updatedAt and returns 304
with no body for a matching If-None-Match, removing the response
serialization cost in addition to the query.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Cache compiled REGEX page rules instead of recompiling per row (#1861)

ruleMatchesPage's REGEX branch recompiled a rule's RegExp on every row it
was tested against -- a hot path shared by every rulesAllow caller (the
graph, visibleTreeItems(), the sitemap build, the admin comment path), and
compilation output depends only on the pattern text. Adds a module-level
Map<string, RegExp | null> cache in helpers/pageRules.ts keyed by the
normalized pattern text (null marking a pattern that failed to compile, so
an invalid pattern keeps failing closed without re-throwing/catching per
row), and wires models/groups.ts#reloadCache() to clear it on every reload
(boot, a local group edit, and every cluster instance's reloadGroups
event) so an edited pattern is recompiled promptly instead of the cache
growing unbounded.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Hoist per-request actor out of graph and recoverable-deletions filters

Both the knowledge-graph route and the recoverable-deletions list were
calling mayOnPage(req, ...) per row, which rebuilds the actor via
actorForRequest(req) on every call. Follows tree.ts's visibleTreeItems()
shape: build the actor once per request, then call
WIKI.models.groups.checkAccess(actor, ...) per row directly. The
graph's page-row input is unbounded, so this was one throwaway actor
object literal per page on the wiki.

OpenProject #1864

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Gate graph node contributor/pageview counts behind ?sizing= (OP #1863)

GET /sites/:siteId/graph's per-node contributor and pageview count
objects dominated the payload and most readers never look at them.
assembleGraph now omits both objects entirely (as keys, not just
zeroed) unless a new `sizing` querystring is present; the route casts
its presence to a boolean and passes it through. Graph.vue sends its
active "Size by" mode as the value on every (re)load, but the backend
gates on presence alone -- both objects always come back together --
since the sizing-mode toggle switches client-side with no refetch.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Cap the graph node set server-side, report truncated/totalNodes (#1866)

assembleGraph() previously returned every readable page with no bound --
multi-megabyte JSON and a multi-second client-side force layout on a
wiki of a few thousand pages, with no degradation path.

Adds GRAPH_NODE_CAP (2000), a deterministic path-sorted cap on the
retained node set, and extends the response shape to
{ nodes, edges, truncated, totalNodes }. Edges are rebuilt from the
capped set so no returned edge references a dropped node. Graph.vue's
loadGraph() reads the two new fields into refs (the truncation-notice
UI itself is OpenProject #1875).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Paginate pageHistory.list with a versionDate keyset cursor, drop authorEmail

GET /sites/:siteId/pages/:pageId/history no longer returns every version of
a page in one unbounded query -- pageHistory.list() now keyset-paginates on
(versionDate, id) descending, returning { items, nextCursor } instead of a
bare array, with an opaque base64url cursor and a 50-row default / 200-row
cap. OFFSET was deliberately avoided: it degrades exactly on the deep
histories this exists to protect, while the existing
pageHistory_pageId_idx (pageId, versionDate) index serves the keyset seek
directly.

list()'s projection also drops authorEmail -- confirmed no frontend
consumer reads version.author.email (PageHistoryOverlay.vue only reads
.author.name) -- via a new PageHistoryListEntry/PageHistoryListAuthor type
and matching PageHistoryListEntry/PageHistoryList API schemas, distinct
from the unchanged PageHistoryEntry/PageHistoryVersion used by
getVersion()/listRecoverable().

PageHistoryOverlay.vue now reads { items, nextCursor } and appends further
pages via a "Load older versions" control rather than assuming the whole
history arrives in one response.

OpenProject #1859

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Batch refreshDescendantPaths write-back into chunked VALUES joins (#1865)

refreshDescendantPaths used to issue two sequential UPDATE statements per
descendant row (one on tree, one on pages for page-type rows) inside the
transaction renameFolder opens -- a folder with a couple thousand
descendants meant thousands of round trips with row locks held throughout.

Keep the per-row hash/path computation in JS (genuinely row-by-row, per
the existing doc comment) but batch the write-back: split the computed
updates into chunks of TREE_UPDATE_CHUNK_SIZE (200) and issue one
UPDATE ... FROM (VALUES ...) per chunk instead of one UPDATE per row.

tree.hash has not been dropped from the schema yet, so both the tree and
pages write-backs are still needed here.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Memoize templated app shell per (lang, isRTL), keyed on shell mtime

setNotFoundHandler's SPA-shell fallback read assets/index.html and
called WIKI.models.locales.getLocales() on every single request, then
re-ran templateAppShell's regex substitution -- all for output that
only varies across a handful of (lang, isRTL) pairs.

getTemplatedAppShell (backend/helpers/appShell.ts) now memoises that
templated output per lang, gated on the shell file's mtimeMs so a live
`npm run build` still invalidates it. resolveIsRTL (which wraps
getLocales()) and the file read only run on a cache miss -- a new lang,
or the first request after a rebuild -- taking getLocales() off the hot
path for the common case of an already-seen lang. Cache-Control:
no-store on the response is unchanged; this is server-side work
avoidance only.

Ref: OpenProject WP #1869

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Paginate the site-wide listRecoverable deleted-pages query (#1862)

listRecoverable scanned every recoverable deletion for a site in one
unbounded DISTINCT ON query, with the read:history permission filter
running in JS afterwards at the route -- the worse of the two
unpaginated history reads per the 2026-08-24 performance audit.

Wrap the DISTINCT ON collapse in a derived subquery and keyset-paginate
the outer query on (versionDate, id) descending, the same cursor shape
sibling WP #1859 is adding to pageHistory.list. Since the permission
filter still has to run in JS after the DB page comes back, nextCursor
is computed from the raw DB page boundary before that filter runs, so
a page shortened by filtering is never mistaken for the end of the
list -- the route just forwards the model's cursor through unchanged.

Updates the three existing bare-array callers (two backend test files,
one admin Vue page) to the new { items, nextCursor } shape; the admin
deleted-pages view now assembles its full list from bounded pages via
a client-side cursor loop instead of one unbounded call.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Show remaining TFA recovery-code count in ProfileAuth.vue (#1874)

GET /users/profile/tfa/recovery-codes was a finished, tested backend
route with no caller. Fetch it for every local-strategy auth method
with 2FA active once the profile auth list loads, and render an "N of
M recovery codes remaining" line with a visible nudge once the
remaining ratio drops to 20% or below.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Show a truncation notice in Graph.vue when the response is truncated (#1875)

Graph.vue's loadGraph() now captures the graph endpoint's truncated/
totalNodes fields into refs, and a persistent, non-dismissable overlay
notice tells the reader when the view is showing a subset of pages and
that filters apply only to that subset -- placed as a plain sibling of
the canvas so it stays visible while panning/zooming.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Set explicit pool.max/connectionTimeoutMillis defaults, document pool: (#1883)

base.yml's pool default was just min: 1, so node-postgres' own max (10)
and connectionTimeoutMillis (0, no timeout) applied silently, and
config.sample.yml had no pool: section for an operator to find. Adds
explicit max: 20 (headroom above the 3 permanently-held LISTEN
connections: event bus, scheduler, collab) and connectionTimeoutMillis:
5000, documents the section in config.sample.yml, and extracts the
Pool-options construction in core/db.ts into a pure buildPoolOptions()
so the defaults, an operator override, and worker mode's forced
{ min: 0, max: 1 } are unit-testable without a real DB connection.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Add tag management mode (rename/merge/delete) to TagsBrowse.vue

Gate a management toggle on manage:pages (userStore.pagePermissions),
listing every tag with inline rename and delete controls. Rename onto
an existing tag's name is treated as a merge. Both mutations confirm
first, naming the affected-page count and noting that pages the actor
cannot manage are left untouched, then refresh the tag list and
current search results on success.

Calls the PATCH/DELETE sites/:siteId/tags/:tag routes from sibling
work package #1873 (PATCH body { newTag }, response { affected }) --
that backend work is not yet implemented in this checkout, so the
feature is inert until it lands, but the frontend is built and
tested to that contract.

OpenProject #1877

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Add /_admin/:siteid/pages inventory (#1880)

Builds a server-paged admin page inventory on the existing
GET sites/:siteId/pages/search route (path/locale/tag/editor/publishState
filters, totalHits) rather than a new backend endpoint -- the replacement
for what the deleted GraphQL AdminPages.vue/AdminTags.vue used to cover,
since /_search caps out at 100 rows with no per-row action.

Wires it into the site nav beside the existing pages/deleted (recovery)
entry, registers the :siteid/pages route, and points the dashboard's
pagesTotal tile at it instead of the site root.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Add PATCH/DELETE /sites/:siteId/tags/:tag with per-page manage:pages enforcement

Rename (and, via the same collapse-on-collision mechanism, merge) and delete a
tag across every page that carries it. manage:pages is a page rule permission,
so it's checked per affected page in the handler rather than declared as a
route permission; a page the caller can't manage is left untouched instead of
failing the whole call. tree.tags is kept in sync alongside pages.tags, and
every touched page is handed to WIKI.models.search.updated so a rename shows
up in search immediately.

OpenProject #1873

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Load PageNewMenu's import dialogs asynchronously (OpenProject #1884)

ImportBatchPageDialog.vue statically imports the full markdown authoring
pipeline (markdown-it + plugins, katex, highlight.js), which was pulled
into every reader's static bundle via PageNewMenu.vue's top-level import
even though almost nobody clicks the import-page menu items. Both dialogs
now load through defineAsyncComponent(() => import(...)), matching the
pattern PageActionsCol.vue already uses for its own dialog() call sites.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Push canRead filtering into listAllForGraph's SQL WHERE (OpenProject #1872)

listAllForGraph used to fetch every page on a site and let assembleGraph's
canRead predicate discard what the caller may not read after the fact, so a
low-privilege reader paid the same DB cost as an admin. helpers/pageRules.ts
gains deriveReadScope(), which reduces an actor's pooled non-DENY rules for a
permission into a safe superset condition (exact/prefix/suffix path,
locale-scoped, or classification-id clauses, OR'd together) -- a page that
fails every clause can never be granted, so it is safe to exclude from the
fetch. Rule shapes that cannot be reduced without risking under-fetching
(REGEX, TAG/TAGALL's case-folded matching, or a rule that already addresses
the whole site) collapse the whole scope to "all", which is exactly today's
unrestricted fetch.

listAllForGraph(siteId, actor) now builds its WHERE from that scope: a
manage:system actor and a 'none'/'all' scope keep today's behavior exactly,
a scoped API key missing read:pages from its own scope returns [] before
ever querying, and a 'clauses' scope pushes the OR of LIKE/eq/inArray
conditions into SQL. assembleGraph's own canRead resolution is unchanged and
still runs in full over whatever rows come back, so the narrowing only ever
has to be a safe superset, never the exact answer.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Add offline locale-pack sideload control to AdminLocale.vue (#1886)

POST /_api/locales/sideload already existed (manage:system-gated,
rescans <dataPath>/locales/ for operator-placed JSON packs) but had
no caller in the UI. Wires the existing admin.locale.sideload /
sideloadHelp strings to a trigger button, gated separately on
manage:system since it's stricter than this page's own site:locale
access, and surfaces loaded/skipped/error results via notify()
before refreshing the locale list.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Move the three permanently-held LISTEN clients onto their own pool (#1887)

The event bus, scheduler, and collaborative editing each check out a
PoolClient via pubsub.ts's connectListener and hold it for the process
lifetime, previously from the main query pool -- silently reducing the
effective application-query ceiling by 3. None of them ever runs an
application query, so give them a small dedicated pool of their own
(helpers/pubsub.ts's createListenerPool, min:0/max:3), built once in
core/db.ts#init() and shared as WIKI.dbManager.listenerPool.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Register v-network-graph and its stylesheet locally in AdminStorage.vue (#1888)

v-network-graph was registered globally in boot/components.js and its
stylesheet globally in css/app.scss, putting a 46 kB gzipped chunk and
103 .v-ng-* CSS selectors into the entry bundle for every visitor, even
though AdminStorage.vue's delivery-path diagram is the sole consumer.

Register the component locally off AdminStorage.vue's existing
`import * as VNG from 'v-network-graph'` namespace import, and move the
stylesheet import into the page's own <style> block (kept unscoped,
since the library renders deeply-nested elements a scoped selector
rewrite would not reach). Extends AdminStorage.test.js with a
mount-based test proving the diagram still renders.

Verified via `npm run build`: the v-network-graph chunk and its
modulepreload link are gone from assets/index.html's entry graph, and
all 103 .v-ng-* selectors moved from main-*.css into AdminStorage-*.css.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Decide OUT for page ratings and Page Data / Page Data Templates (WP #1890)

Both are fully-toggled but entirely inert: ratings have no wired
handlers, no backend route, and a mistyped ratingCount column;
Page Data's two dialogs have no persistence and an unreachable entry
point. Neither is worth completing over cutting -- ratings duplicate
the existing comments system with no confirmed demand, and Page
Data's only real product backing (WIKI3_ASSESSMENT.md idea #1)
describes a different design than what exists. Records both
decisions in docs/variances.md; the carry-out children (#1907, #1903,
#1911) are updated separately in OpenProject to name the OUT branch.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Version glossary per-term CRUD writes (OpenProject #1891)

createTerm/updateTerm/deleteTerm now record a glossary_versions
snapshot in the same transaction as the write, whenever an actor is
given -- closing the gap where an API-key client's direct per-term
edit was invisible to a later "restore previous version" and would
be silently reverted. The four per-term REST routes stay as a
legitimate programmatic surface (decision recorded on the work
package): they have no in-repo caller, but are fully permissioned,
documented, and cheaper for an external client to use for a single
term than round-tripping the whole export/import payload.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* feat: admin page inventory with row selection and bulk delete/render/retag (#1882)

Adds POST /sites/:siteId/pages/bulk (delete/render/retag), permission-checked
per page so a denied page is reported as skipped rather than failing the whole
batch -- unlike the classification-conflicts-resolve route's all-or-nothing
precedent. Retag is add/remove-relative against each page's own existing tags.

Also builds the /_admin/:siteid/pages inventory itself (#1880's own scope),
since that dependency had not landed yet: server-side paged/filtered list with
per-row and select-all-on-page selection driving the new bulk endpoint, wired
into AdminLayout's nav and AdminDashboard's pagesTotal tile.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Add batched parent-classification lookup to models/pages.ts (#1897)

parentClassification() issued one query per call, and the
classification-conflicts resolve route (api/pages.ts) calls it once per
submitted page id. Add parentClassifications(), resolving the immediate-parent
floor for a set of (locale, path) pairs in a single query over the distinct
ancestor paths, returned as a map keyed by input path. The single-page method
now delegates to it so its three existing callers (resolveCreateClassification,
updatePage, movePage) keep their exact behavior, including the optional tx
parameter movePage threads through.

Covered by two new DB-backed cases in models/pages.test.ts: a mixed set of
paths whose ancestors differ (including one with no classified ancestor at
all) matching the per-call result, and a same-named parent path in two
different locales proving the query is scoped per locale rather than
matching locale/path independently.

Wiring this into api/pages.ts's resolve loop is out of scope here -- that is
OpenProject #1902, a sibling child of #1894.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Wire the two undecided caller-less routes: watch PATCH and storage setup POST (#1895)

Both `PATCH .../pages/:pageId/watch` and `POST .../storage/targets/:targetId/setup`
already had working, tested backend implementations with no frontend caller.
Rather than delete them as dead code, this adds the missing UI for each:

- InboxWatching.vue gains a per-page notification-preferences menu (delivery
  mode + edited/moved/deleted toggles) that PATCHes the existing watch route.
- AdminStorage.vue's Setup card now also renders while a target's setup is not
  yet configured, with a "Start/Continue Setup" button that POSTs the next
  step via a new nextSetupStepName() helper. Unlike the previously-removed
  GitHub App flow (task 509), this stays module-agnostic -- no module-specific
  behavior is added, only the generic step-driving mechanism the backend
  contract already supports, and its DELETE twin (Uninstall) already had a
  real caller that this keeps reachable.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Fix post-merge lint/format: drop unused AccessActor import, reformat pageRules.test.ts

* Fix post-merge ReferenceError: userStore undefined in TagsBrowse.vue

wp-1877-tag-management-mode's canManageTags computed referenced
userStore without importing/instantiating useUserStore(), which broke
every TagsBrowse test (18/18 failing) after merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Fix duplicate apiErrorMessage import after merging wp-1886-sideload-locale-upload

The merge left two identical import statements for apiErrorMessage in
AdminLocale.vue, tripping oxlint's no-duplicate-declaration check.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* Drop ratingScore/ratingCount columns from pages table

Ratings were decided OUT (OpenProject #1890, Epic #1885): every
consuming surface is dead code with no backend route, so carry that
decision through the schema rather than fixing ratingCount's mistaken
timestamp type. Drops both columns with a generated migration, and
unpicks the propagated wrong-type fallout: pageHistory.ts's version-
diffing exclusion sets no longer name either column, and the
azure-search test fixture no longer fakes a Date to satisfy the old
ratingCount type.

OpenProject #1907

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Import highlight.js/lib/common at both hljs call sites (#1901)

frontend/src/renderers/markdown.js and EditorCodeBlockMenu.vue both
imported the highlight.js package root, which registers every one of
the ~190 grammars the package ships into a module-singleton registry
shared by both. Switch both to highlight.js/lib/common (~36 languages)
together, so neither call site leaves the full set bundled/registered
for the other. Re-document EditorCodeBlockMenu.vue's header comment to
describe the actual (now-trimmed) registered set, and add coverage in
markdown.test.js for a retained language still highlighting and a
trimmed-but-real language falling through to escaped plain text.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Document backup/restore scope and container mounts in docs/operations.md

Adds docs/operations.md stating the honest two-source recovery scope
(a pg_dump of the Postgres database plus the dataPath filesystem
volume and config.yml), the restore order, and the container mounts
the image expects at /wiki/data, covering the five writer
subdirectories (locales/, cache/icons, cache/files, exports/,
imports/) rather than just content/. Links the doc from README.md and
adds a structural test asserting the document exists, is linked, and
names every subdirectory the models actually write, so it cannot
silently drift from the code.

Resolves OpenProject #1900 (part of Epic #1892).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Add a multi-row record path to models/auditLog.ts

recordMany() writes N audit log entries in one INSERT instead of N
sequential record() calls, for callers that would otherwise loop
record() once per item (starting with the classification-conflict
resolve route, OpenProject #1894/#1902). An empty array is a no-op
that issues no statement.

OpenProject #1899

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Remove Page Data / Page Data Templates (OpenProject #1911)

Branch: OUT, per #1890 -- the two dialogs, the store slot and the
disabled rail entry point are deleted rather than built out.

- Delete PageDataDialog.vue and PageDataTemplateDialog.vue
- Drop the pageDataTemplates state slot from stores/site.js
- Remove the disabled "Page Data" rail button and togglePageData()
  from PageActionsCol.vue
- Drop the dead async component registration from SideDialog.vue
- Reword stale doc comments in SideDialog.vue and WTabs.vue that
  referenced the deleted PageDataDialog
- Strip the 27 editor.pageData.* keys from every backend/locales/*.json
  file (Localazy propagates the same key set to all locales)
- Add regression coverage: the store no longer exposes
  pageDataTemplates, and the rail never renders a Page Data button
  even with the experimental flag on

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Decide multi-arch publishing: amd64-only, both workflows agree (#1916)

build.yml carried a commented-out linux/amd64,linux/arm64 platforms line
inherited from upstream with no fork decision behind it. Deletes it, records
the amd64-only decision and QEMU-cost reasoning in docs/variances.md, and adds
a structural test asserting build.yml and release.yml's platforms: values
match and neither carries a commented-out platform line.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Cache locale strings in WIKI.cache, invalidate in reloadCache()

getStrings() ran a fresh SELECT on every call, unlike getLocales()
immediately above it — a ~190 KB JSONB read paid on every cold page
load. Cache per code under localeStrings:<code>, mirroring the
locale:<code> shape getLocales() already uses, and drop every known
code's entry in reloadCache() (the existing single invalidation
point, called from sideloadFromDataPath) so a sideloaded pack is
visible on the next call.

OpenProject #1915

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Add GET /_api/sites/:siteId/pages/:pageId/backlinks (OpenProject #1914)

Returns pages whose extracted internal links (models/rendering.ts's
extractInternalLinks, stored in the pages.links jsonb column) target the
requested page, via a single jsonb `@>` containment query -- the same
pattern models/classificationLevels.ts already uses. Each candidate row is
filtered through mayOnPage(req, 'read:pages', siteId, row), the same
per-row permission check api/graph.ts uses for graph edges, so a linking
page the caller may not read is silently dropped rather than counted.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Answer GET /locales/:code/strings with an ETag and a 304 (#1920)

Computes a sha1 ETag over the strings payload, honors if-none-match
with an empty 304, and sends Cache-Control: public, no-cache so
browsers revalidate rather than caching stale strings past a locale
sideload. Documents the response headers on the route schema.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* Make docsBase a server-provided setting from base.yml (#1922)

siteStore.docsBase was a hardcoded literal (https://docs.js.wiki) that
nothing ever assigned, and didn't match README.md's own documentation
link. Add it to base.yml, surface it on buildSitePayload() alongside
pdfExportAvailable (so it reaches both sites/:siteIdorHostname and
bootstrap for free), and delete the frontend literal -- the value now
always arrives from the server, so no fallback default is wanted.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Add optional structured context to core/logger.ts JSON mode

logger.ts formatted exactly four fields in JSON mode (timestamp, instance,
level, message), with message an already-built string - opaque to an
aggregator. Level methods now accept an optional second context object,
merged into the JSON payload as siblings of message. Context is spread
before the four fixed fields so it can never clobber them, and is ignored
entirely in text mode. A context-free call's JSON output stays byte-
identical to before.

OpenProject #1934

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Remove page ratings UI/config entirely (OpenProject #1903)

#1890 decided page ratings are out of scope. Carries that decision
through the admin, author and reader surfaces: drops the ratingsMode
control from AdminGeneral.vue, the allowRatings toggle from
PagePropertiesDialog.vue, the stars/thumbs reader block and dead
currentRating state from Index.vue/SideDialog.vue, and the matching
allowRatings/ratingsMode fields from the page and site stores.

Backend: removes allowRatings from the page config shape (models,
schema, history restore) and ratingsMode from the site config shape
(seeds, schema), along with the now-orphaned locale strings across
all 56 locale files. No db migration needed -- both fields live in
JSONB config blobs, not real columns.

Also cleans up a features.ratings leftover in AdminGeneral.vue's
default config that #1893 missed on the frontend side.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Build the backlinks side dialog and enable View Backlinks (OpenProject #1917)

Adds PageBacklinksDialog.vue, a side-dialog panel fetching GET
sites/:siteId/pages/:pageId/backlinks (OpenProject #1914, sibling WP not yet
landed) and listing each source page as a link to its path, with an empty
state when none exist. Registers it on SideDialog's sideDialogComponent map
and wires PageActionsCol's "View Backlinks" menu item to open it, dropping
its disabled attribute and experimental-flag gate.

hasPageActions is updated to reflect that the "..." Page Actions menu can no
longer come up empty now that Backlinks is unconditional -- previously it
gated the whole menu on the experimental flag or Rerender Page's own
availability, which would have made the newly-unconditional entry
unreachable for an ordinary reader. Convert Page's own gate, and deleting
hasPageActions once Convert Page itself goes, is left to OpenProject #1921.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Declare and emit approval:* webhook events (OpenProject #1932)

Adds approval:submitted, approval:approved and approval:rejected to
HOOK_EVENTS/EMITTED_EVENTS and wires the emit() calls into
models/approvals.ts's saveSubmission/approveSubmission/rejectSubmission,
beside their primary writes, following the page:*/comment:* convention.
rejectSubmission now takes an actor param so it can record who declined
a suggestion and carry that into the event payload.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Emit page:classification-changed, guarded on the level actually changing (OpenProject #1935)

Declares the event in HOOK_EVENTS/EMITTED_EVENTS and emits it from
updatePage() alongside page:edit, but only when the incoming
classification differs from the page's current one -- a patch that
merely restates the current level (the editor sends every field on
every save) must fire nothing, since a webhook on a no-op change is
worse than no webhook for the compliance integrations this event
exists for. Payload carries id/path/locale/siteId plus
previousClassification/classification, matching page:rename's
previousX/x convention.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Delete help buttons pointing at fork-only docsBase concepts (OpenProject #1929)

Sweeps the 12 docsBase-based help buttons whose deep path names a concept
that exists only in this fork (classification, glossary, approvals, feature
flags, multi-site admin, cluster monitoring, in-browser terminal, metrics,
scheduler, custom blocks, table editor, dev API) -- no docs site, upstream
or this fork's own eventual one, can describe a concept this fork invented,
so the dead buttons are removed rather than left to 404. The remaining ~30
docsBase usages describe concepts inherited from upstream Wiki.js and are
left in place. GroupEditOverlay.vue's three links are WP #1925's separate
scope and are untouched here.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Fix pre-existing selector bug in AdminGeneral hostname-rename tests

wrapper.get('[aria-label="Site Hostname"] input') looked for a
descendant input inside an element carrying that aria-label, but
WInput.vue puts the aria-label directly on the <input> itself. Drop
the trailing " input" so the selector matches the element it's
actually on. Found while merging wp-1903-remove-ratings-surfaces --
unrelated to that change, but in a file the merge touched.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Decide comment providers: unavailable, not codeTemplate embed (#1953)

Records the product decision in docs/variances.md: reverse the
codeTemplate/isSelectable() porting for Disqus/Commento/Artalk rather
than build the vendor-embed render path. Nothing consumes the stored
choice today, and standing up third-party script embeds on every page
view is a bigger trust-boundary commitment than restoring a picker
option nobody currently depends on. Carry-out (isAvailable:false on
the three definition.yml files, dropping codeTemplate from
isSelectable()) is #1958; the stored-provider dead-end guard is #1962.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Record decision: ship diagram rendering as an MCP tool (WP #1944)

Decides the ship-or-retire question from Epic #1941 for
backend/api/diagrams.ts and backend/models/diagramRender.ts: ship a
render_diagram MCP tool (carried out in #1946) rather than retire the
published, Swagger-documented endpoint. Auth, rate limiting, size caps
and timeouts already exist in the model, so the tool wrapper is a thin
adapter; no docs/variances.md entry is needed since nothing is removed.

No application code changes — this WP is decision-only.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Add failed-job and db-pool gauges to /metrics (WP #1939)

Revisits the /metrics scope decision (task 594): the answer is to
extend, not hold. Adds wikijs_jobs_failed_total (jobHistory rows
currently in the failed state -- not a lifetime total, since
cleanJobHistory prunes on a retention window) and three
wikijs_db_pool_* gauges read off WIKI.dbManager.pool. Everything
stays a plain gauge in the existing hand-rolled writer, so the
original no-prom-client rationale still holds; only the gauge count
grows from six to ten.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Add dev/setup.sh clone-to-running script for non-devcontainer path

A contributor on the non-devcontainer path had to run four npm installs, a
config copy and two builds by hand -- the same sequence
.devcontainer/app-init.sh already automates for the container path.
dev/setup.sh does the same for everyone else: installs backend/frontend/
blocks/e2e, creates config.yml from config.sample.yml only if absent, and
builds frontend and blocks. Idempotent and safe to re-run. README's Generic
Setup now points at it instead of duplicating (and drifting from -- it still
referenced the removed ux/ workspace and node server) the command list.

OpenProject #1966

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Ship render_diagram as an MCP tool (WP #1946)

Decision (see #1946's implementation-plan comment): ship rather than retire —
#1941's own framing treats shipping as the default path, and no deliberate
retirement decision was ever recorded on the decision child (#1944).

Adds backend/mcp/tools/renderDiagram.ts, delegating to the existing
WIKI.models.diagramRender so its size caps and CustomError throws (missing
Puppeteer, offline PlantUML, oversized source) carry through unchanged, mapped
onto McpToolError the same way createPage/updatePage already do. Applies the
same RENDER_LIMIT rate-limit policy the REST route's limitRenders preHandler
uses (now exported from helpers/rateLimit.ts), keyed by the caller's userId
where available so a personal access token shares its budget with the web UI.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Document config precedence, $(ENV:default), and pool/cache tunables (#1976)

config.sample.yml never mentioned the $(VAR:default) substitution
parseConfigValue applies to it, the three-source precedence between
base.yml, config.yml and the DB settings table, or the pool/
files.cacheMaxSize tunables. Adds a header block covering all three,
naming the DB-owned key groups from models/settings.ts and the
effective defaults (pg's own max of 10, cacheMaxSize's 512MiB).

Also fixes docs/tls-termination.md's step 3, which told operators to
set trustProxy in config.yml — the seeded security settings row
overwrites that on every boot, so the admin Security page is named as
the way that actually sticks.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob

* Mark Disqus/Commento/Artalk unavailable, drop codeTemplate from isSelectable (#1958)

Carries out the decision left unresolved on sibling WP #1953: no page-view
code has ever rendered a codeTemplate provider's embed, and building that
render path turned out to need a new public per-page-permission-gated API
plus vendor-specific glue for three SDKs -- far more than the config flip
it looked like for a low-severity product-honesty wart. isSelectable() now
gates on hasImplementation alone; the three definition.yml files declare
isAvailable: false, which AdminComments.vue already renders as a disabled,
unselectable row. docs/variances.md records the reversal of the 2026-08-18
decision that ported codeTemplate in the first place.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Sessio…
dylan-hart added a commit to dylan-hart/wiki that referenced this pull request Sep 1, 2026
…UI polish (#35)

* Guard AdminUtilities.vue export-exclusions hint against a broken doc link

WP #2360 found admin.utilities.exportExclusions pointing at
docs/operations.md before that file existed. The doc has since landed
(task #1985 / Epic #1892), so the hint is already accurate -- add a
regression test that parses the referenced docs/*.md path out of the
locale string itself and asserts it exists, so a future rename or
removal of that doc can't silently reintroduce a dead reference.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Localize renderedContent.js's four accessible-name/tooltip strings

'Copy code', 'Copied', 'Copy link to this section' and 'Link copied' were
left hardcoded when the clipboard-failure notify message was localized in
the same file. The t() function was already threaded through the same
call sites, so route these four through it too via new
common.renderedContent.* locale keys.

Closes OpenProject #2357.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix stale showLocaleFilter doc comment in Graph.vue (#2330)

The comment claimed the locale filter control also disappears once
narrowed active filters (tags/folderDepth) leave only one locale
represented. That's not what the code does: localeOptions is derived
from filterOptions, which is deliberately computed from allNodes (the
full unfiltered set), not the currently-filtered set -- same
universe-of-choices design filterOptions' own comment documents and
tagOptions already follows. No behavior change; corrects the comment
to describe the actual, intentional behavior.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Add regression test for approve/reject route 401 response schema

OpenProject #2355 found the reject submission route's response schema
missing a 401 entry even though the handler can return
reply.unauthorized(). That fix already landed on this branch (commit
c47b73ed), but nothing guarded against a future regression: the
generic responseErrors.test.ts check only scans routes with a
non-empty config.permissions, and approve/reject are deliberately
unpermissioned at the route level (checked in-handler instead).

Add a narrow test using the same recording-app technique that asserts
both the approve and reject submission routes declare 401 and 404 as
ApiError refs. Verified it fails against the pre-fix shape (401
entry removed) and passes with it restored.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Delete dead Convert Page menu entry and hasPageActions workaround (#1921)

Now that #1917 landed View Backlinks as a live, unconditional item, the
"..." Page Actions menu can never come up empty, so the placeholder
Convert Page entry and the hasPageActions computed that existed only to
guard against an empty menu are both dead weight. Deletes the disabled
menu item, its flagsStore/manage:pages gate, the now-orphaned
common.page.convert locale string, and inlines hasPageActions' template
condition down to what's actually left. Adds a guest-permissions Vitest
case covering the scenario hasPageActions used to guard.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix AdminAuditLog retention Save button misalignment (OpenProject #2331)

The days input carries validation :rules, so w-input reserves a hint/error
row below its visible box. items-end was aligning the Save button to the
bottom of that whole reserved area instead of the visible field, making
the row read as jarringly misaligned. Switch to items-center, matching
how the rest of this file aligns mixed-height flex rows.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix tiny admin header icons: pass size="64px" through WIcon (#2332)

frontend/src/layouts/AdminLayout.vue's global `.admin-icon { height: 64px }`
rule only actually sizes a bare <img> (AdminPages.vue). Every other admin
page applies the class to <w-icon>, whose scoped `.w-icon` rule
(width/height: 1em) out-specificities the plain global class, so the icon
fell back to a 1em box against the ambient font-size instead of rendering
at 64px.

Fix all 36 affected admin page headers by passing size="64px" through
WIcon's own `size` prop (sets font-size, which actually controls the
em-based box) instead of trying to out-specificity WIcon's scoped rule
from outside. AdminPages.vue's plain <img> case is untouched -- the
global .admin-icon rule still legitimately sizes it.

Adds a source-level regression test scanning every Admin*.vue page for
`<w-icon class="admin-icon">` and asserting it carries size="64px".

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Add a Go Back affordance to the Inbox layout (OP #2334)

Clicking the bell in HeaderNav drops a reader into /_inbox with no way
back to whatever page or admin area they were previously viewing --
InboxLayout offered no back/close affordance at all.

Capture the route the reader arrived from once, in onMounted (the
layout is shared across /_inbox/watching and /_inbox/review, so it
mounts only once per real entry from outside the inbox), and surface
it as a "Go Back" rail entry ahead of the existing Inbox/Pending
Review items -- reusing the same common.actions.goback string and
la:arrow-circle-left icon pages/Search.vue's own back button already
uses. Falls back to home when there's no captured browser history
(a bookmarked or emailed deep link into the inbox).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Collapse repeated Scheduler history rows by task (OpenProject #2337)

storageSyncTick's every-minute cron tick was dominating the Scheduler
admin area's Completed/Failed job-history tabs (~1440 rows/day against
a 500-row server cap), drowning out every other task. A task with more
than one entry in the list now collapses into a single summary row
carrying its most recent run plus a "N runs" badge; clicking it expands
in place to list every individual run, still fully inspectable and
retryable.

- frontend/src/helpers/jobHistoryGrouping.js: pure grouping/flattening
  logic (groupJobHistory, flattenJobHistoryRows), unit tested in
  isolation.
- frontend/src/pages/AdminScheduler.vue: wires the flattened rows into
  the existing table, adds the expand/collapse toggle, and gates the
  retry action to real (non-synthetic) rows only.
- backend/locales/en.json: three new admin.scheduler.* strings for the
  badge and toggle aria-labels.

No scheduler/backend behavior changes -- purely how the existing
history list renders.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Thread an accessible name through the dialog/overlay consumers (#1620)

WDialog already accepted labelled-by/aria-label, but ~50 of the app's
components/*Dialog.vue and *Overlay.vue consumers never wired either one, so
their <w-dialog> panel announced as an unnamed "dialog" to assistive tech.
Each now binds :aria-label to the same i18n key its own visible header
already shows (mirroring the pattern already used by ChangePwdDialog,
UserChangePwdDialog, TreeBrowserDialog and WConfirmDialog), including
dialogs with a conditional/mode-dependent header and PagePropertiesDialog's
own nested relation sub-dialog.

SideDialog.vue and MainOverlayDialog.vue wrap dynamically-swapped async
content with no header of their own; both now compute their aria-label
from a small lookup of the currently-mounted child's own title key.

Adds a source-scan regression test (frontend/src/components/
dialogAccessibleName.test.js, in the style of css/_page-contents.test.js)
asserting every <w-dialog usage under components/ carries labelled-by or
aria-label, so a future dialog added without one fails the suite.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix logout clearCookie missing sameSite, matching cookie registration

Logout's reply.clearCookie(sessionCookieName()) already carried path and
secure (from an earlier fix), but not sameSite -- unlike index.ts's
fastifySession cookie registration, which sets sameSite: 'lax'. Add the
same option to the clearCookie call so its attributes match the cookie
being cleared exactly, and add coverage asserting the Set-Cookie header's
Path/Secure/SameSite attributes for both the default secure deployment and
the security.cookieSecure: false escape hatch.

Resolves OpenProject #2336.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Remove dead post-bypass site-pin check in checkSiteAccess (OpenProject #2338)

WP #2338 described checkAccess()/checkSiteAccess() evaluating the
manage:system bypass before the API-key site pin, defeating the pin. That
was already fixed (OpenProject #2189/#2199, landed the same cycle this WP
was filed from) -- both methods correctly refuse a pin mismatch ahead of
the manage:system bypass, with existing DB-backed regression coverage
(groups.test.ts's "a site-pinned actor is refused checkSiteAccess for a
different site, even holding manage:system" case) already proving it.

What was left: checkSiteAccess() still carried a second, now-unreachable
copy of the site-pin check AFTER the manage:system bypass, from before the
pre-bypass guard existed. By the time execution reached it, actor.siteId
was already guaranteed either null or equal to siteId, so it could never
actually refuse anything -- pure dead code, misleadingly read as if still
enforcing something. Consolidated onto the single pre-bypass
withinSitePin() guard, matching checkAccess()'s shape. No behavior change.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Show pageview tracking evidence, not just the on/off toggle (#2335)

AdminPageviews.vue was a bare toggle with zero stats or counts, so an
admin had no way to tell whether pageview logging was actually
recording anything. Add Pageviews#summary() (total views, last 24h,
last 7d, distinct pages, most recent view), thread it through the
existing GET /system/pageviews response, and render it as stat tiles
(or an explicit "no views yet" state) on the admin page.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix req.apiKey never populated outside /_api/ (OpenProject #2339)

index.ts's Bearer-verification onRequest hook only ever looked for a
token when req.url.startsWith('/_api/'), so req.apiKey stayed null for
every request to controllers/files.ts (/_files), controllers/site.ts
(/_site) and controllers/thumb.ts (/_thumb) regardless of whether a
valid token was sent -- silently defeating those controllers'
enforceApiKeySite() calls (files.ts, site.ts) and the equivalent
actorForRequest()-mediated site-pin check (thumb.ts).

Adds helpers/apiKeySite.ts#isBearerAuthenticatedPath(), a pure
URL-prefix check covering /_api/, /_files/, /_site/ and /_thumb/, and
wires it into index.ts in place of the old bare '/_api/' check.
controllers/render.ts and controllers/icons.ts are deliberately left
out: render.ts resolves no site and is only ever fetched by this
instance's own headless browser (no API key involved), and icons.ts
never reads req.apiKey at all.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix: remove duplicate write:pages checkAccess() in approveSubmission()

approveSubmission() called WIKI.models.groups.checkAccess(actor, 'write:pages',
...) twice against the identical actor/page/siteId -- once before the
vote-recording transaction (OpenProject #2160/#2165) and again after it, when a
call finalizes the submission (also #2165). Both were added independently by
overlapping work packages #2150/#2165 and never de-duplicated during the merge.

Since nothing about actor or page changes between the two calls within one
synchronous invocation, the second check could never evaluate differently from
the first -- it was dead code, and worse, its failure path left the submission
already marked 'approved' in the DB with the page never actually written,
contradicting the method's own documented "leaves it pending" behavior.

Removed the redundant post-transaction check, keeping the pre-transaction one
that existing tests already rely on, and tightened the ApproveSubmissionResult
JSDoc's three overlapping 'forbidden' paragraphs (also merged independently by
the same two WPs) into one accurate description.

Verified against a real Postgres instance that both existing behaviors hold
unchanged: a reviewer without write:pages is refused before any state changes
(submission stays open/pending), and a reviewer with write:pages still
succeeds and writes the page.

Fixes OpenProject #2340.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix pre-existing sandbox failure in index.test.ts's spawned boot test

The spawned `node backend` child process index.test.ts's dbManager.init()-failure
test drives has no Temporal global on this sandbox's sub-26 Node, crashing before
it ever reaches the db-init failure path the test exercises. Add a second --require
preload (mirroring spoofSupportedNodeVersion.cjs) that installs the real
@js-temporal/polyfill, feature-detected so it is a no-op on a real Node 26 host,
matching the pattern test/temporal.ts#ensureTemporal() already uses for this file's
own in-process tests.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix docs/variances.md #2174 entry: correct nonexistent sanitizeSvgAsset reference

The entry claimed SVG upload sanitization was implemented in
models/assets.ts#sanitizeSvgAsset, but no such function exists. The real
implementation is an inline call to sanitizeSvg() (helpers/images.ts) from
models/assets.ts's asset-creation flow.

OpenProject #2344

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix block-drawio strokeAttrs() coercing strokeWidth 0 to 1

strokeAttrs() used `Number(props.strokeWidth) || 1`, which treats a
strokeWidth of 0 (a legitimate draw.io value meaning "no visible
stroke") as falsy and silently overrides it to 1. Switch the fallback
to a Number.isFinite() check so only a genuinely non-numeric/malicious
strokeWidth (NaN) falls back to 1, while 0 passes through unchanged.
The Number() coercion itself is unchanged, so the #2143 XSS hardening
(never interpolating strokeWidth as a raw string) is preserved.

Fixes OpenProject #2343.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix isPrivateIPv6 to catch legacy IPv4-compatible embedding (::a:b)

isPrivateIPv6's canonical-binary rewrite only detected the IPv4-mapped
embedding (::ffff:a:b), missing the legacy IPv4-compatible embedding
(::a:b, RFC 4291, deprecated but still accepted by net.isIP). The
WHATWG URL parser normalises ::169.254.169.254 into ::a9fe:a9fe before
this function ever sees it, so it was not being flagged as private,
allowing SSRF to cloud metadata endpoints via this address form.

OpenProject #2345

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix nav GET route's OpenAPI description contradicting read:pages behavior

The description claimed the per-item read:pages filter on generated
(auto/mixed) entries "runs regardless of full", but getNav() actually
passes actor as null when full/unfiltered is true, which skips that
filter entirely -- matching the model's own doc comment and the
visibility-group filter it already skips. Corrected the description
to say full skips both filters, and added a regression test pinning
the wording so the two can't drift apart again. Doc-only change, no
behavior change (OpenProject #2342).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix CORS REGEX auto-anchoring for top-level alternation (WP #2348)

`^${pattern}$` only anchors the left edge of the first alternative and
the right edge of the last one in a pattern with top-level `|`
alternation, leaving every other alternative unanchored and still
substring-matchable against the Origin header. Wrap the pattern body
in a non-capturing group before anchoring so `^(?:A|B)$` fully anchors
every alternative.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix DB-backed test crash: WIKI.dbManager not set on setupTestDb's WIKI global

`helpers/advisoryLock.ts`'s real `withAdvisoryLock` reads `WIKI.dbManager.config`
to build its dedicated lock pool, but `test/db.ts#installTestWiki()` only ever
installed `WIKI.db`, so any DB-backed suite exercising the real lock (not a
dependency-injected fake) crashed with "Cannot read properties of undefined
(reading 'config')". This is what the deadlock-regression test added for
OpenProject #2252 hit.

Add a minimal `dbManager: { config: { connectionString } }` stub to the WIKI
global setupTestDb() installs, mirroring the shape helpers/advisoryLock.test.ts
already hand-rolls. Verified against a real Postgres instance that
dispatch-storage.test.ts's previously-crashing deadlock-regression test now
passes.

OpenProject #2347

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Add regression guard for duplicate migration index creation (#2350)

WP #2350 cited two migrations both creating jobs_waitUntil_createdAt_idx
(20260825202921_main from #2081, and 20260825203757_main attributed to #1364).
Investigation found no such duplicate anywhere in this branch's history: only
20260825202921_main creates that index, matching schema.ts's single
declaration, and 20260825203757_main does not exist on scarlett or in git
history at all. The Issue this was spun off from (#2303) is itself marked
"not independently verified" and was recorded against an external diff, so
the finding never reflected trunk state -- writing a "drop the duplicate"
migration here would have deleted the sole legitimate index #2081 added to
fix scheduler.processJob's sequential-scan-on-every-poll cost.

Adds a permanent guard instead: a test walking every migration.sql in
chronological order that fails if any migration CREATEs an index name
already live from an earlier migration (a DROP INDEX of that name first
legitimately clears it for reuse). Currently passes, and would have caught
the scenario WP #2350 described had it ever actually reached trunk.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix DB-backed system.test.ts: seed corsMode, add setErrorHandler (#2346)

The DB-backed 'Write routes record an audit entry' suite (WP #2231)
never seeded WIKI.config.security.corsMode, so Security#validate()
rejected every PUT /security patch on the undefined CORS mode; with no
app.setErrorHandler registered, that thrown badRequest() then failed
to serialize against the ApiError# schema and fell back to a 500
instead of the asserted 200.

Also swaps the test payload's trustProxy field for disallowIframe:
trustProxy's oneOf[boolean, string] schema turns out to be genuinely
ambiguous under Fastify's default AJV coerceTypes and always rejects
a real boolean value in production too -- filed separately as #2366
so this fix stays scoped to the test fixture.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Verify update-locales already routes through the HA broadcast path (OpenProject #2352)

WP #2352 (spun off from an automated review finding, Issue #2323) reported that
update-locales.ts calls WIKI.models.locales.reloadCache() directly instead of the
HA cache-broadcast path. That is no longer true: the task already calls
broadcastReload() (fixed under OpenProject #2032, landed 8 minutes after this
finding was generated), which reloads the local cache and emits a `reloadLocales`
event for every other cluster instance to pick up.

The existing no-DB unit suite stubbed broadcastReload but never asserted its call
count, so it didn't directly prove the broadcast path was used (only that a call
to a nonexistent reloadCache would have thrown). Add explicit assertions that
broadcastReload is called exactly once when a row was upserted, and not at all on
a no-op run, so this WP's exact concern has a direct regression guard going
forward.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Add regression test guarding against #2341's described duplicate read:pages check

Investigated the reported duplicate: getReviewableSubmissions() filters `rows`
into `matchedRows` via matchesPage() (approval-rule path/tag matching, which
never calls checkAccess), and only then filters `matchedRows` into
`readableRows` via a single checkAccess(actor, 'read:pages', ...) call. git
blame confirms only one commit (a3a6c799) ever added that checkAccess call --
there is no second, redundant filter pass in the current source to remove.

Added a DB-backed regression test to the existing
"read:pages/read:source/write:pages gating (OpenProject #2160/#2165)" describe
block that spies on the real WIKI.models.groups.checkAccess and asserts it is
called exactly once per matched row for a getReviewableSubmissions() call, so
a future re-introduction of the duplicate fails a test instead of just costing
an extra query pass. Verified the assertion's logic against the real
implementation with a throwaway script (not committed) that worked around the
unrelated pages.scripts migration-drift bug filed separately as #2367, which
currently blocks every DB-backed test that calls pages.createPage() --
including the pre-existing tests in this same describe block -- in this
environment.

Fixes OpenProject #2341.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix scheduler timeout requeue clobbering a later retry's result (#2351)

executeInProcess() cannot actually cancel an in-process task once
taskTimeout trips, so a task like exportContent that calls
setResult(jobId, ...) after being abandoned could still clobber the
result of a later, legitimately-completed retry sharing the same
jobHistory row.

Fence setResult() against a stale write by threading the claim's
attempt number through AsyncLocalStorage (helpers/jobExecutionContext.ts)
rather than SimpleTask's own signature, which would collide with
import-content.ts/dispatch-storage.ts's existing deps parameter. A
setResult() call whose captured attempt no longer matches
jobHistory.attempt is dropped (and logged) instead of overwriting a
later retry's result.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Route block-include's remaining 4 error messages through i18n resolver

Fixes OpenProject #2358: only 2 of 6 reader-facing error messages
(pageNotFound, includeFailed) were routed through the i18n resolver.
The self-include, loop, max-depth and password-protected messages
stayed hardcoded in English. Adds the four missing en.json keys
(blocks.include.errors.{selfInclude,loop,maxDepth,passwordProtected})
and switches those branches to await t(...), matching the pattern
already used for the other two.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix approveSubmission leaving a submission stuck approved on write failure

approveSubmission's finalizing transaction commits status: 'approved'
while it still holds the submission row lock -- needed to serialize a
concurrent reviewer's approve on the same quorum-reaching call, since
updatePage() can't run inside that transaction (its own history/
watcher/search/hook/storage I/O). But that status commit is only a
claim: if the subsequent write:pages check refuses, or updatePage()
itself throws, nothing undid it. The row was left permanently
'approved' with no write behind it, and no retry path -- every other
query here (getReviewableSubmissions, getOwnSubmission, the entry
guard, the transaction's own re-check) only acts on status: 'open'.

Add revertFailedFinalization(), called from both failure paths, to
undo the claim (status back to 'open', resolvedBy cleared) before
returning/rethrowing. This also fixes the write:pages-refused branch,
which had the identical defect despite its own comment claiming
otherwise.

OpenProject #2349

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Chunk pageHistory and navigation inserts in siteImport (OpenProject #2353)

importSite() already chunked pages/tree/asset inserts to stay under
Postgres's 65535 bind-parameter limit, but the pageHistory and
navigation inserts were single unchunked statements -- a large enough
site archive would blow the limit and abort the restore with an
opaque driver error.

Add PAGE_HISTORY_INSERT_CHUNK_SIZE (14 cols) and
NAVIGATION_INSERT_CHUNK_SIZE (5 cols), following the same
floor(MAX_BIND_PARAMETERS / columnCount) pattern already used for
pages/tree, and loop both inserts through chunk() inside the same
transaction so a mid-chunk failure still rolls back atomically.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix approveSubmission missing status='open' guard (OpenProject #2354)

approveSubmission's finalizing UPDATE had no status='open' guard on its
WHERE clause, unlike rejectSubmission's own final UPDATE -- add the same
guard plus a rowCount check, treating a zero-rowcount result as
not-found instead of unconditionally reporting finalized: true. The
for('update') row lock taken just above already serializes this against
a concurrent writer at the Postgres level, so this is defense-in-depth
matching rejectSubmission's pattern rather than the only thing
preventing a resolved row from being flipped back to 'approved'.

Adds a concurrent approve-vs-reject DB-backed test, verified in both
race orderings via a standalone script (not committed) after patching
around an unrelated, separately-filed migration bug (#2369) that
currently blocks all DB-backed page-creation tests in this repo.

OpenProject #2354.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Wire WDialog's aria-label through every real dialog call site (OpenProject #2356)

WP #1617's WDialog labelledBy/ariaLabel infrastructure was never actually wired
up anywhere, so every dialog's role="dialog" panel stayed unnamed for
assistive tech. Nearly every dialog renders its own ad-hoc header rather than
WCardHeader, so labelledBy isn't applicable case-by-case; the pattern already
established by WConfirmDialog/ChangePwdDialog/TreeBrowserDialog is followed
instead: :aria-label reuses the exact same expression already driving the
dialog's visible header text.

- ~50 simple modal dialogs: :aria-label set to their existing static or
  computed title expression.
- A handful with a conditional/parameterized title (WebhookEditDialog,
  SiteActivateDialog, BlockParamsDialog, ClassificationReportDrillDialog,
  BlockCredentialDialog, AdminNavEditDialog): same approach, reusing the
  expression/computed already driving the visible header.
- RerenderPageDialog: reuses its one visible message string, since it has no
  dedicated title.
- SideDialog, MainOverlayDialog, AdminLayout's admin-overlay: these wrap a
  dynamically-swapped <component :is=...> with no title of its own, so each
  gets a small lookup map keyed by the loaded component name, mirroring that
  child's own already-existing header translation key.
- AdminBlocks, AdminIcons: same mechanical wiring for their inline dialogs.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix oxfmt emphasis-style drift in docs/variances.md

A stray *something* emphasis marker predated this merge; oxfmt --check
flags it (underscore style). Fixed while merging wp-2344's
sanitizeSvg reference correction into this branch.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* Fix Temporal ReferenceError in dispatch-storage deadlock-regression test

Merging wp-2347's WIKI.dbManager stub let this test's real withAdvisoryLock
call reach contentSync.recordSuccess for the first time (previously it
crashed earlier reading dbManager.config off undefined). recordSuccess calls
Temporal.Now.instant() unconditionally, and this sandbox's Node lacks the
native Temporal global -- the file never called the codebase's own
ensureTemporal() polyfill helper. Add that call, matching the same pattern
already used in models/contentSync.test.ts and other DB-backed suites.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix: users.ts unknown-group rejection uses ERR_UNKNOWN_GROUPS locale key

The unknown-group-id rejection in POST/PUT /users returned a hardcoded
English string instead of the ERR_UNKNOWN_GROUPS locale key that the
identical check in api/approvals.ts already uses, so this instance
never localized. Both call sites now send the coded message, and the
existing OpenProject #1603 group-validation tests assert on it.

Fixes OpenProject #2363.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Add regression guard against duplicate locale keys (OpenProject #2362)

The specific duplicate reported for #2362 (auth.errors.unexpectedResponse
appearing twice in backend/locales/en.json from an unreconciled merge) is
no longer present on scarlett -- a later merge already reconciled it. What
was missing is a guard against this class of bug recurring: JSON.parse
silently keeps only the last occurrence of a duplicate key, so nothing
would have caught the original duplicate mechanically. Add a test that
scans en.json as raw text and fails if any top-level key string is ever
defined more than once.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Move Graph.vue's graphAccessibleName aria-label into graph.* i18n keys (WP #2359)

The canvas's accessible-name computed still built its sentence from a
hardcoded English template literal, even though WP #1690's own header
comment claimed this move as its scope. Splits the sentence into three
graph.accessibleName.* keys (page/link pluralized via the existing
"{count} x | {count} xs" pipe convention graph.tooltip.* already uses,
composed into a summary template) so the rendered English text is
unchanged but the string is now translatable.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix account-keyed login rate limit to return 429, not 400 (OpenProject #2361)

The account-keyed limiter (consumeAccountAuthAttempt, consumed inside
users.login()/loginTFA()) threw a plain Error('ERR_RATE_LIMITED'), which the
route's ERR_-prefix check mapped to a generic 400 -- unlike the IP-keyed
limitAuthAttempts hook, which answers 429 with Retry-After. Introduce a typed
AccountRateLimitedError carrying retryAfter so api/authentication.ts's login
and tfa routes can map it to the same 429 + Retry-After contract instead.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix WMenu Escape discarding via commit-on-blur instead of blur

WMenu bound its Escape/close handler on document in the capture phase, so
it ran and moved focus (firing a synchronous blur) before a focused
field's own Escape handler ever got a turn -- committing an in-progress
edit instead of letting the field discard it.

Switch to the bubble phase so a focused descendant's own Escape handler
runs first (target phase), before the key bubbles up to WMenu's document
listener. Also updates PageActionsCol.vue's now-partially-stale comment,
which documented this exact bug as its own workaround's rationale.

OpenProject #2364

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix E2E Smoke Suite: disable auth rate limiting for the Playwright config

PR #35's checks turned up 12 e2e failures spanning nearly every spec, all
timing out on the same post-login assertion. The suite's 20+ real login
submissions (loginAsAdmin, called by most specs) share one runner IP and
one account, tripping base.yml's auth rate limit (10 attempts/2m, 15m ban)
within the first two minutes -- every login after that gets a 429 the
frontend has no retry path for, for the rest of the job.

No e2e spec exercises the limiter itself, so turning it off for this
suite only (config.e2e.yml, the same file enforceCsp is already flipped
here for its own e2e-specific reason) costs nothing.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix E2E Smoke Suite: opt e2e config out of Secure/__Host- session cookies

Disabling the auth rate limit alone (previous commit) got every login PUT
back to a clean 200, but the suite still failed identically -- because
security.cookieSecure defaults to true, which marks the session cookie
Secure and names it __Host-wikiSession (backend/index.ts, helpers/
security.ts). A browser never stores a Secure cookie over a connection it
sees as plain HTTP, and playwright.config.js's BASE_URL is
http://localhost:3000 with no TLS anywhere in the chain -- so every login
"succeeded" server-side while the browser silently dropped the Set-Cookie
header, leaving the very next request unauthenticated. Index.vue's route
watcher then reads that as a guest hitting a pageless site and bounces
back to /login, which is exactly the "still on the login form" state
every failing spec's error-context snapshot showed.

security.cookieSecure: false is base.yml's own documented way out of this
for a plain-HTTP deployment. This looks to be a pre-existing gap rather
than something this branch introduced: scarlett's own CI history has no
completed, non-cancelled Build job (the one that runs Playwright) since
before cookieSecure started defaulting to the hardened __Host- name, so
nothing had caught it yet.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix E2E Smoke Suite: Monaco's mount-time focus() was stealing focus from the Title field

Root cause of the remaining page-publish/multi-site/permissions e2e failures,
found via a Playwright trace: EditorMarkdown.vue's onMounted unconditionally
called editor.focus() as its very last step ("// -> Post init"). Everything
above that line -- the user-settings/site-blocks prefetch, Monaco's async
chunk load, monaco.editor.create() -- takes real time, so this can land well
after the page has visibly rendered.

The smoke suite's own createAndPublishPage helper clicks the page Title field
(a plain contenteditable with no autofocus of its own -- PageHeader.vue) and
starts typing immediately, exactly the window this races. On a loaded CI
runner, Monaco's mount consistently finished mid-type: its focus() call stole
focus from the Title field, and every keystroke meant for the title -- plus
the page body typed right after -- landed in Monaco instead. The trace's own
screenshot showed it directly: the Title field still empty (placeholder
showing), Monaco's buffer holding "<title><body>" concatenated with no
separator. TreeBrowserDialog's save dialog then correctly refused to save
with "Missing Page Title", which is what the e2e failures actually surfaced
(toHaveURL never seeing the new page's URL, because Save was a no-op).

Fix: only claim focus at mount if nothing else already has it (a fresh
mount's default document.activeElement is <body>) -- preserves today's
default editor-focuses-itself behavior when nothing raced it, while no
longer stealing focus from a field the author already moved to. Two new
regression tests cover both branches directly against the existing Monaco
mock.

This looks to be a long-standing bug (the code dates to the original
GraphQL-to-REST migration, well before this batch), not something introduced
by this PR's own changes -- scarlett's CI has had no completed, non-cancelled
Build/e2e run in its history, so nothing had caught it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix E2E Smoke Suite: CSP script-src blocked the app shell's own bootstrap scripts

csp.spec.js's inline-script violation ("Executing inline script violates the
following Content Security Policy directive 'script-src 'self''") turned out
to be a real, shipped bug, not a test-only concern: the app shell
(assets/index.html) always ships two inline <script> blocks with no src --
the Temporal-polyfill feature-detect check in frontend/index.html itself, and
temporalPolyfillChunkPlugin's substituted chunk-url assignment -- and
base.yml's own shipped cspDirectives default (script-src 'self', no
'unsafe-inline') refuses both outright. Turning enforceCsp on with the
shipped default policy broke the app shell for every visitor, not just this
suite; e2e/config.e2e.yml deliberately flips enforceCsp on specifically to
exercise that shipped default (WP #2166), which is exactly what caught it.

Fix: helpers/security.ts#inlineScriptHashSources reads the app shell's own
inline scripts and returns their exact 'sha256-<base64>' CSP hash sources;
index.ts computes them once at startup (same "read once, restart to pick up
a change" contract every other setting in this registration already has) and
merges them into script-src before registering @fastify/helmet. Verified
against a real `npm run build` output: the two computed hashes
(sha256-HECzgbMH7gTNY2+55a9bJDe7xvrW74PGTZV5AlQEuI0= and
sha256-hG9e1P2YOuE/zfURv9msIvCoxSP6xZ7mVWaZT99InXE=) match the exact values
Chromium's own CSP violation reports named. No policy loosening -- 'self'
plus these two exact, built-content hashes is what actually lets the shipped
app shell run under its own default CSP.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix E2E Smoke Suite: scheduler.spec.js's Schedule tab test was stale (4 vs 16 seeded jobs)

scheduler.spec.js asserted the Schedule tab lists exactly the original four
seeded cron entries (checkVersion, cleanJobHistory, purgeRateLimits,
updateLocales), with updateLocales's original cron ('0 0 * * *', since
changed to '30 0 * * *' to stop it racing checkVersion for the same tick).
JOB_SCHEDULE_SEED (backend/models/jobs.ts) has grown to sixteen entries
since this test was written -- purgeSessions, purgeExports, purgeImports,
purgePageviews, cleanAuditLog, purgeContentSyncState, purgeUserKeys,
purgeGuestPii, storageSyncTick, storageDailyBackup, sendWatchDigests and
purgePageWatchEvents all landed after it, with nothing keeping the test in
step. `toHaveCount(4)` against the real 16-row table is what
e2e/tests/scheduler.spec.js's own CI run actually reported failing.

Fix: import JOB_SCHEDULE_SEED directly (same cross-workspace import
rtl.spec.js already uses to import backend/scripts/seed-rtl-test-locale.ts,
and confirmed here to resolve and import cleanly with no WIKI/database
dependency) and assert against it rather than a hardcoded copy -- the count
and every task/cron pair now come from the same source of truth the app
itself seeds from, so this can't go stale the same way again.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix E2E Smoke Suite: loginAsAdmin's login-success signal doesn't exist below 900px

viewport-narrow.spec.js overrides the viewport to 390x844 (task 2114) --
narrower than HeaderNav.vue's 900px isActionsCollapsed breakpoint, below
which the account/admin/notification buttons AccountMenu.vue would
otherwise render (including .account-avbtn) fold into
HeaderActionsMenu.vue's "More Actions" dropdown instead. loginAsAdmin's own
post-login wait was hardcoded to .account-avbtn alone, so at this viewport
it timed out waiting for an element the header genuinely never renders --
confirmed via a Playwright trace: the raw DOM snapshot at timeout has no
account-avbtn class anywhere, while the header itself (Home link, site
title, Search/More Actions buttons) rendered normally.

Fix: wait for .account-avbtn OR the "More Actions" trigger (always
rendered, at either breakpoint) -- the two are mutually exclusive by width,
so this is a correct, viewport-agnostic signal with no behavior change for
every other spec's pinned 1280x800 viewport, where "More Actions" never
renders and the wait resolves exactly as before.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix E2E Smoke Suite: e2e.yml never builds blocks/, so every /_blocks/*.js 404s

csp.spec.js's remaining failure (block-checklist never appearing) traced to
the webServer's own boot log: "root" path ".../blocks/compiled" must exist
-- unlike build.yml's own e2e leg, this standalone workflow (the one that
actually runs on a pull_request, per its own header comment) never runs
`npm run build` in blocks/, so blocks/compiled/ never exists and every
/_blocks/*.js request 404s (confirmed against the run's own NotFoundError
log lines for block-checklist.js, block-tabs.js, block-tab.js and
block-infobox.js). No custom block content element ever upgrades, so
csp.spec.js's own coverage of "does a real block render without a CSP
violation" never got past the 404 to actually exercise it.

Fix: add the same "Build blocks" step build.yml's e2e leg already has,
plus blocks/package-lock.json to the actions/setup-node cache-dependency-path
alongside the other three workspaces.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix E2E Smoke Suite: permissions.spec.js's Escape keypress was cancelling the whole user-create dialog

Root cause (real product bug, filed separately as OpenProject #2370, not
fixed here): WDialog.vue's Escape-to-close handler is a capture-phase
document listener, which always runs before WMenu.vue's own Escape handler
(moved to bubble phase by OpenProject #2364) ever gets a turn. Pressing
Escape to close the still-open Groups multi-select dropdown was instead
seen by the enclosing, non-persistent UserCreateDialog first, which
silently cancelled the whole dialog -- confirmed via a Playwright network
trace showing zero POST to users after the click and no new row in the
final table.

Fix here: close the dropdown by clicking the dialog's own header text
instead of pressing Escape -- an outside click as far as WMenu's own
click-away catcher is concerned, with no Escape keypress (and therefore no
WDialog interaction) involved at all.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix E2E Smoke Suite: closing the Groups dropdown by clicking dialog text still hit WMenu's outside-click catcher

Follow-up to the previous permissions.spec.js fix: clicking the dialog's
header text to close the still-open Groups dropdown ran into the exact
kind of obstruction that fix was trying to route around from a different
angle -- WMenu's own full-viewport outside-click catcher sits above the
rest of the dialog while its popup is open, so the header text underneath
it isn't actually clickable either (Playwright's actionability check
correctly refuses to click through an intercepting element rather than
force it, and keeps retrying until it times out at 30s).

Fix: click the Groups combobox trigger again instead -- the same toggle
path (WMenu.vue's onTriggerClick) that opened it, and the one thing in the
dialog guaranteed to still be clickable regardless of the catcher, since
it's bound directly to the trigger element rather than reachable only
through hit-testing everything on top of it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix E2E Smoke Suite: closing the Groups dropdown via a second click still hit WMenu's catcher

Second follow-up: re-clicking the Groups trigger to toggle its dropdown
closed still failed. The failure's own action log named the culprit
directly -- WMenu's outside-click catcher (<div class="fixed inset-0">)
intercepts the trigger's own click too, not just other elements in the
dialog, so no click-based approach in this dialog works while the
dropdown is open.

Fix: press Tab instead of clicking anything. WSelect.vue's own onKeydown
already closes the dropdown on Tab (case 'Tab': isOpen.value = false),
bound directly to the trigger's keydown rather than requiring a click, and
Tab is a different key entirely from WDialog's Escape-specific capture-phase
handler (OpenProject #2370), so it never touches that code path either.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix E2E Smoke Suite: stop trying to close the Groups dropdown, force the Create click instead

Third follow-up. Tab (WSelect.vue's own documented close-on-Tab path)
still left the dropdown open in a real run's final DOM -- most likely
raced by WDialog's capture-phase Tab-trap (trapTab(), refocusing the
panel's first field) before WSelect's own target-phase handler gets a
turn, the same capture-vs-bubble ordering problem as the Escape case
(OpenProject #2370).

Rather than keep guessing at a fourth interaction to route around the
same underlying bug, stop trying to close the dropdown at all: its open/
closed state is purely visual (state.userGroups already committed the
selection immediately on the option click, per WSelect.vue's select()),
so nothing about submitting correctly depends on it. { force: true } on
the "Create" click dispatches straight on the button, bypassing the
hit-test WMenu's outside-click catcher would otherwise win.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix E2E Smoke Suite: the force-click landed on WMenu's catcher, not the Create button

Fourth follow-up. { force: true } skips Playwright's actionability wait
but is still a real, coordinate-based click -- it lands on whatever is
actually topmost at that point, same as a real cursor would. Confirmed
against a real run: it hit WMenu's outside-click catcher (closing the
dropdown, visible in the next snapshot as no longer expanded), not the
"Create" button underneath it, so create() never ran.

Fix: keep the forced click (it's what closes the dropdown, exactly like a
real "click outside to dismiss" would) and follow it with a second,
ordinary click on the same button -- now unobscured, since nothing is
left on top of it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix backend unit tests hanging forever: websocketOrigin.test.ts's WIKI.collab stub was missing refuse()

Root cause of "Typecheck, Lint, Format & Unit Tests" hanging indefinitely
on PR #35 (and, it turns out, on every push to scarlett since core/collab.ts
was last touched -- c47b73ed, "Cycle: security hardening, release
integrity, and backlog fixes (#34)" -- since scarlett's own CI history has
no completed run since then either, matching the same "rapid pushes cancel
each other before anyone notices" pattern the e2e fixes on this same PR
found).

Reproduced locally against a real Postgres: node --test
test/websocketOrigin.test.ts hangs forever on "a same-origin handshake
reaches the controller" for the /_collab/:siteId/:pageId case specifically.

controllers/collab.ts's every refusal branch -- including the very first,
the isValidUuid check this exact test relies on -- calls
WIKI.collab.refuse(conn, code, reason) instead of conn.close() directly
(core/collab.ts's own doc comment: it sends the close frame, then a grace-
period timer that forcibly terminates a client that ignores it). This
suite's own before() stub only ever defined WIKI.collab.capture, so that
call throws "WIKI.collab.refuse is not a function" inside the websocket
handler -- an error @fastify/websocket does not turn into a close frame,
leaving the socket open. The test's own wait for the close event
(`ws.once('close', ...)`, no timeout, since node --test itself runs with
no --test-timeout either) then hangs forever, and with node --test's
default concurrency, this one hung file is enough to keep the whole
`npm run test` process running indefinitely.

Fix: add a refuse stub to the suite's WIKI.collab mock, closely mirroring
the real implementation (closes the socket with the given code/reason) --
matches what every one of this suite's own assertions already expects a
refusal to look like. Confirmed fixed: node --test test/websocketOrigin.test.ts
now completes in the low tens of milliseconds instead of hanging.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

* Fix a second infinite hang: collabWorker.ts's WIKI.dbManager stub was missing listenerPool

Same class of bug as the websocketOrigin.test.ts fix, in a different file,
also pre-dating this PR (core/db.ts, where listenerPool was added as a
pool separate from the main one, was last touched by c47b73ed -- "Cycle:
security hardening, release integrity, and backlog fixes (#34)" -- the
commit right before this whole run started).

Reproduced locally against a real Postgres: node --test core/collab.test.ts
hung indefinitely on the "collaborative editing across instances
(DB-backed)" describe block's before() hook, which spawns two worker
threads via startInstance() (core/collab.test.ts) and awaits a ready
message with no timeout of its own.

Each worker (collabWorker.ts) builds its own minimal WIKI global and calls
the real core/collab.ts#init(), which LISTENs via
helpers/pubsub.ts#connectListener against WIKI.dbManager.listenerPool.
The worker's stub only ever set WIKI.dbManager = { pool } -- no
listenerPool -- so connectListener's pool.connect() throws on undefined.
That's caught by connectListener's own resilience loop (reconnect()'s
`while (!closed)`, meant for a genuinely dropped connection reconnecting
on its own), which retries forever every retryDelayMs (3s) and logs
nothing, since this worker's WIKI.logger.warn is a no-op. init() never
resolves, the worker never posts its ready message back, and
startInstance()'s promise -- and the whole describe block's before() hook
-- hangs forever.

Fix: point the stub's listenerPool at the same pool already being built.
This worker's own test scenarios have no reason to keep the two pools
genuinely separate the way a real instance does. Confirmed fixed: all 44
tests in core/collab.test.ts now pass, including the previously-hanging
suite, in about 15s total.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MWmnuVYcWTJDjkZz2PX4q

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants