Skip to content

Release: merge development into beta - #126

Merged
rubenvdlinde merged 452 commits into
betafrom
development
Aug 20, 2026
Merged

Release: merge development into beta#126
rubenvdlinde merged 452 commits into
betafrom
development

Conversation

@github-actions

@github-actions github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Automated PR to sync development changes to beta for beta release.

Merging this PR will trigger the beta release workflow.

Reminder: Add a major, minor, or patch label to this PR to control the version bump. Default is patch.

Comment thread .github/workflows/cli-release.yml Fixed
Comment thread .github/workflows/cli-release.yml Fixed
Comment thread .github/workflows/code-quality.yml Fixed
Comment on lines +19 to +33
runs-on: ubuntu-latest
# No observed runs yet; a Go vet+test of a stdlib-only CLI is minutes at most.
timeout-minutes: 20
defaults:
run:
working-directory: cli
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22"
- run: go vet ./...
- run: go test ./...

build:
Comment on lines +34 to +74
needs: test
runs-on: ubuntu-latest
# No observed runs yet; a stdlib-only Go cross-compile is minutes at most.
timeout-minutes: 20
defaults:
run:
working-directory: cli
strategy:
matrix:
target:
- { goos: linux, goarch: amd64 }
- { goos: linux, goarch: arm64 }
- { goos: darwin, goarch: amd64 }
- { goos: darwin, goarch: arm64 }
- { goos: windows, goarch: amd64 }
- { goos: windows, goarch: arm64 }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22"
- name: Build static binary
env:
GOOS: ${{ matrix.target.goos }}
GOARCH: ${{ matrix.target.goarch }}
CGO_ENABLED: "0"
run: |
ext=""
[ "$GOOS" = "windows" ] && ext=".exe"
out="doriath-${GOOS}-${GOARCH}${ext}"
go build -trimpath -ldflags "-s -w -X main.version=${GITHUB_REF_NAME}" -o "$out" .
echo "ARTIFACT=cli/$out" >> "$GITHUB_ENV"
- uses: actions/upload-artifact@v4
with:
name: doriath-${{ matrix.target.goos }}-${{ matrix.target.goarch }}
path: ${{ env.ARTIFACT }}
- name: Attach to release
if: startsWith(github.ref, 'refs/tags/cli-v')
uses: softprops/action-gh-release@v2
with:
files: ${{ env.ARTIFACT }}
Conduction Release Bot and others added 8 commits August 5, 2026 19:56
Two files still declared `SPDX-License-Identifier: AGPL-3.0-or-later` while
every other licence signal in the repo (LICENSE, composer.json, package.json,
appinfo/info.xml and all 241 lib/**.php @license tags) says EUPL-1.2.

- tests/e2e/visual/_visual-helpers.ts
- tests/integration/run-newman.sh

Header-only change; PHP (710 tests) and vitest (425 tests) suites are
byte-identical before and after. gate-28 license-triangle: PASS -> PASS.
…ation-2026-08-05

chore(license): normalise 2 stray AGPL-3.0 SPDX declarations to EUPL-1.2
…its path (#162)

* fix(apphost): register OpenRegister's autoloader instead of guessing its path

Apps register in sorted order (OC_App::getEnabledApps() sort()s the list, and
Coordinator::registerApps() calls registerAutoloading() then register() one app
at a time), so doriath's register() runs before OCA\OpenRegister\ is
autoloadable. The previous workaround include_once'd
../../../openregister/vendor/autoload.php, which assumes both apps share one
apps directory and silently does nothing on a multi-apps_paths install.

Resolve the path through IAppManager::getAppPath() and hand it to
OC_App::registerAutoloading(), which touches only the autoloader and is
idempotent. Deliberately NOT IAppManager::loadApp(), which would mark
OpenRegister loaded and boot it before its own register() had run.

The class_exists(Bootstrap::class) guard now wraps the call so an absent
OpenRegister degrades instead of aborting the rest of register().

* fix(apphost): make the autoload prelude testable and teach the analysers OC_App

CI found three real problems with the first version of this change:

- psalm and phpstan both fail on \OC_App: it is Nextcloud's legacy bootstrap
  class, server-private and absent from nextcloud/ocp. There is no OCP
  interface for registering another app's autoloader. Added to the same
  ignore lists that already carry OC, OCA\OpenRegister\, Doctrine and Guzzle.
- the coverage ratchet dropped 55.78% -> 55.74%, because an inline prelude in
  Application::register() cannot be reached by a unit test — Application
  needs a Nextcloud DI container to construct.

Moving the prelude into AppInfo\OpenRegisterAutoloader fixes the second
properly rather than by lowering the baseline: the contract that actually
matters — this NEVER throws, so it can never abort the caller's register(),
which is the very defect the prelude exists to prevent — is now directly
asserted, along with its idempotence.

Verified in the container (PHP 8.4): phpstan OK, psalm no errors, PHPCS clean,
PHPMD clean on the new file, and both new tests pass.

* fix(spec): point the prelude's @SPEC anchor at doriath's own spec

gate-46 (spec-anchor-existence) resolves @SPEC targets inside THIS repo. The
anchor named openspec/specs/apphost-boilerplate/spec.md, which is
OpenRegister's canonical spec file and does not exist here, so it could never
resolve. It also used a ' — Requirement: ...' suffix that no other @SPEC in
this repo uses.

Points at doriath's own openspec/specs/apphost-adoption/spec.md instead.
…ne (#164)

The standing 'Release: merge development into beta' PR has head_ref
'development', so its pull_request run rendered the same concurrency group as a
push to development. cancel-in-progress killed the push run, which is the only
carrier of the push-only jobs (Coverage Baseline Check, SBOM, Features
Extract). Those jobs report 'skipped' on the surviving PR run, which renders
like a pass, so the gate never produced a verdict.

Suffixes -push on the group for main/development pushes only; feature-branch
dedup is unchanged. No gate weakened: no waiver, baseline, threshold or
continue-on-error.

Same fix as openconnector#1158.
…#166)

.coverage-baseline was read as a floor by the phpunit guard and as an exact
target by the push-side staleness check. Together they demand equality with a
checked-in constant, which against a moving base branch is not satisfiable:
closing "stale" means committing the value the tree will measure after the PR
lands. Measured on openregister — committed 58.93, development advanced
16030->16038 tests, merge result measured 58.88, guard reported a 0.05% drop.

coverage-guard.php gains --against=<clover.xml>, naming a report measured at
the merge base. When present it is the only floor; the committed constant is
reported but not enforced. Both numbers then come from one driver in one job,
so the xdebug/pcov statement-counting difference cancels rather than being
baked in, and the merge base cannot go stale.

Ratios are compared as exact integer cross-products, not rounded percentages:
at two decimals a one-statement regression read as "unchanged" and exited 0.
An empty or zero-statement report is now a hard error rather than 0%, which as
the merge-base side would set the floor to zero and pass every drop.

Verified on real CI clover artifacts: a genuine 1.44% drop fails, an unchanged
tree passes, and adding untested code fails while adding tested code passes.
…bles, not OpenRegister

Written 2026-07-27 and left uncommitted since. Recording the decision where it
belongs rather than leaving it as an untracked file on one machine.
* chore(ci): move hydra-gates-ref v1.3.0 -> v1.4.0

A pinned `hydra-gates-ref` is a silent expiry date on every upstream fix:
this repo cannot receive a gate-package change until this line moves.

v1.4.0 is the latest tag and the first one that carries
`hydra-gates/scripts/axe-run.cjs` (verified absent at v1.3.0), so it is
also the first that has ConductionNL/.github#168 axe DOM scoping and
ConductionNL/.github#165 gate-46 fix.

`enable-axe` is deliberately NOT enabled in this commit. Ordering matters:
the ref lands first, enabling axe is a separate decision.

* chore(ci): stop pinning hydra-gates — track the package at @main

Removes the `hydra-gates-ref` input from the `quality.yml` caller. The
shared workflow already defaults it to `main`, and this repo consumes
`quality.yml` itself at `@main`, so dropping the override makes both
sides move together: a gate-package fix lands here without a commit here.

A pin is a silent expiry date on every upstream fix, and we have paid for
that twice already:

  - .github#159 — 22 repos sat on v1.0.1, which predated the gate fixes.
    16 gates were dead fleet-wide and every single one reported PASS. A
    gate that never runs emits a tick identical to one that did, so
    nothing in any repo's history showed it.

  - .github#173 — the shared side flipped a default at @main while the
    package stayed pinned per caller. Old runners lacked the coverage
    accounting the new default assumed, so they went red on gates they
    had no subject matter for.

Removing the pin closes both shapes at once. Rolling back is a revert on
ConductionNL/.github main, which reaches the whole fleet in one commit;
holding this one repo still is still possible by setting the input
explicitly, with a reason.

`enable-hydra-gates: true` is unchanged. `enable-axe` remains unset.
The comment block that justified the pin is replaced with a short note
saying why there is no pin.
Comment thread .github/workflows/release-development.yml Fixed
rubenvdlinde and others added 18 commits August 6, 2026 13:49
…ommand injection) (#170)

quality / Security (composer) is red on every PR here as of today:

    Advisory ID: PKSA-rdkp-vv9z-mjkg
    CVE: CVE-2026-67434  —  OS Command injection
    Affected versions: <3.13.6|>=4.0.0,<4.0.2
    Reported at: 2026-08-05T23:53:11+00:00

The advisory was published YESTERDAY and roave/security-advisories installs
as dev-latest each run, so the same lockfile was clean on 2026-08-05 and is
vulnerable on 2026-08-06 with no commit in between. The last green run is
evidence of when it ran, not that the lockfile is safe.

composer.json's existing constraint already permits the fixed version, so
this is a lockfile move only: 1 update, 0 installs, 0 removals. Verified the
diff touches exactly two lines, both the version string, and no other file.

Part of a fleet sweep — 13 of 16 repos checked were on the affected 3.13.5.
…ontract failures (#171)

* fix(tests): remove unbounded RSA-4096 keygen from the timed unit-test path

`Frontend Tests (unit)` failed on `development` (run 31083918823):

    tests/vitest/emergencyEnvelope.spec.js > emergency recovery envelope
      > builds an envelope the grantee can open, recovering the grantor key
    Error: Test timed out in 5000ms.

The test does not hang. It calls a real WebCrypto RSA-4096 `generateKeyPair()`,
which is a random prime search with unbounded runtime — measured here at
105-681ms over 10 samples on an idle machine, and several times that on a
contended two-core runner executing 69 spec files across parallel workers.
Against vitest's 5000ms per-test default that is a coin flip, and the same
construct already lost it once before in run 30884131373
(`tests/store/import.spec.js`). Caching the generated pair per file (#148)
removed the repeats but left the FIRST keygen of every file on the timed path,
which is the flake that has now surfaced in a second spec.

Fix: the fixtures module ships two committed RSA-4096 key pairs (PKCS#8 +
SPKI, generated out-of-band with openssl) and imports them with
`crypto.subtle.importKey`. Import is bounded and sub-millisecond, so the
variance is gone outright. Nothing about the code under test changes: the keys
are genuine 4096-bit RSA with 512-byte OAEP blocks, so `src/crypto/rsa.js`'s
chunking framing is exercised exactly as before and every assertion still runs
real RSA-OAEP. These specs assert round-trip behaviour, never key freshness.

Where key *generation* is itself the subject, the spec still generates:
`rsa.spec.js` keeps `generateKeyPair emits a valid SPKI PEM re-importable by
importPublicKey` calling `generateKeyPair()` directly, so that path stays
covered. Net keygens on a 5000ms path: 8 -> 1.

Converted alongside, same defect class:
  - tests/extension/crypto.spec.js  — 4 inline 4096-bit keygens, one per test
  - tests/store/import.spec.js      — the residual first-call keygen
  - tests/vitest/attachment-crypto.spec.js — one keygen per beforeEach

Proof it can fail, and that the fix is what stops it — identical 42-way CPU
contention (3x oversubscription on 14 cores), same command:

  before:  Tests 1 failed | 3 passed (4)   Error: Test timed out in 5000ms.
  after:   Tests 17 passed (17)            EXIT=0

Full suite, identically conditioned, before -> after:
  69 files / 425 tests passed -> 69 files / 425 tests passed
  test time 31.80s -> 18.59s
Coverage ratchet: 53.32% >= baseline 18.31%, unchanged by this commit.

No timeout was raised, no assertion weakened, no test skipped.

* fix(api): bind the RFC 7523 snake_case grant_type; provision the suite Newman needs

`Integration Tests (Newman)` has failed every run since the gate was enabled
on 2026-08-05 (before that it was `skipped`, so these assertions had never
once been checked against a live app). 19 assertion failures, two independent
causes. Both are fixed at the cause; no assertion was weakened.

CAUSE 1 — a real product bug: no client can obtain a machine token
------------------------------------------------------------------
`ApplicationTokenController::exchange(string $grantType = '')` relied on
Nextcloud's dispatcher to bind the request parameter, and the dispatcher binds
by EXACT name. The wire name is the snake_case `grant_type` — RFC 7523 §2.1,
and what this app's own `.well-known/doriath` discovery document tells every
client to send. It never bound. `$grantType` was ALWAYS the empty-string
default, so the endpoint answered `400 unsupported_grant_type` to every
request, including well-formed ones.

The bug hid behind a test that passed: 'Unsupported grant type -> 400' expects
`unsupported_grant_type`, which is what a broken endpoint returns for ANY
input, so it went green while the endpoint was completely unusable.

Measured on the CI-equivalent replica (NC 32.0.12 + doriath), same three
requests, `origin/development` code vs this commit:

  request                                          before            after
  grant_type=password                              unsupported_gt    unsupported_gt
  grant_type=jwt-bearer (no assertion)             unsupported_gt    invalid_request
  grant_type=jwt-bearer, assertion=not.a.jwt       400 unsupported   401 invalid_grant

The fix reads the canonical wire name off the request and keeps the camelCase
argument as a fallback, so callers written against either spelling work.

CAUSE 2 — the collection assumed ambient vault state it never created
---------------------------------------------------------------------
The other 17 failures are all secret writes. The collection's own description
said the CRUD round-trip is drivable 'because the dev vault already has an
unlocked suite'. True against a long-lived dev instance; the CI job installs a
FRESH Nextcloud where admin has no encryption suite at all, so every write was
refused by SecretService::getActiveSuiteOrBlock().

The refusal is invisible from the status line. These are OCSController routes,
and Nextcloud's OCSMiddleware rewrites a 403 from an OCSController into an OCS
v1 envelope — served as HTTP **200** with the real code in
`ocs.meta.statuscode`. Measured against a suite-less user:

  POST /api/v1/secrets -> HTTP 200, 181 bytes
  {"ocs":{"meta":{"status":"failure","statuscode":403,
   "message":"No active encryption suite — it may be revoked or not yet
   created"},"data":[]}}

which is exactly the '200 OK, 864B' run 31083918823 recorded, and why `b.id`
was undefined and the follow-up requests addressed `/secrets/null`.

The collection now provisions its own precondition instead of inheriting it:
'SETUP: ensure the caller has an active encryption suite' POSTs a real,
committed test RSA-4096 public key (it must be real — CaService::signPublicKey
parses it and mints a certificate; encryptedPrivateKey is opaque client-side
ciphertext, so a placeholder is honest there). It runs ONLY when the caller has
no active suite: two active suites are as unusable as zero, because
getActiveSuiteOrBlock() resolves exactly one and blocks with the same 403
otherwise — verified, and the reason the request is conditional rather than
unconditional.

Verification — full runner against a CI-equivalent replica with admin's suite
revoked first, i.e. the exact fresh-instance condition:

  doriath collection  85 assertions / 17 failed  ->  95 assertions / 0 failed
  machine collection  26 assertions /  2 failed  ->  26 assertions / 0 failed
  run-newman.sh EXIT=1 -> EXIT=0

Re-run on the same instance (suite now present) skips the SETUP request and
still exits 0 at 91 assertions — the idempotency the collection advertises.

phpunit 712 tests / 2287 assertions before and after, identical.
phpmd unchanged at 35 findings, byte-identical finding set.
phpcs clean on the changed controller.

KNOWN, NOT FIXED HERE: POST /api/v1/suites will happily mint a SECOND active
suite for a user, which bricks every subsequent secret write with the same
masked 403. Filed as a follow-up rather than fixed silently in a CI PR.

* fix(security): squizlabs/php_codesniffer 3.13.5 -> 3.13.6 (CVE-2026-67434)

`Security (composer)` went red on 2026-08-06 with:

    Found 1 security vulnerability advisory affecting 1 package:
    Package: squizlabs/php_codesniffer
    Advisory ID: PKSA-rdkp-vv9z-mjkg
    CVE: CVE-2026-67434

Packagist's advisory API is the authority here:

    affected: <3.13.6 | >=4.0.0,<4.0.2
    reported: 2026-08-05 23:53:11

so 3.13.6 is exactly the fixed release, and this is a lockfile-only move
inside the existing `^3.9` constraint — 7 insertions, 7 deletions, one
package. phpcs re-run at 3.13.6: 242/242 files, EXIT=0.

WHY THIS SITS IN THIS PR RATHER THAN ITS OWN
The shared quality workflow gates the heavy jobs on the security job:

    if: ... && needs.security.result != 'failure'   (quality.yml:1464, :1720)

so while `Security (composer)` is red, PHPUnit, E2E and **Newman** are all
SKIPPED. The Newman fix in the previous commit could not be verified by CI at
all until this is green — the skip is a cascade, not a verdict.

⚠️ A LOCAL `composer audit` IS NOT A CONTROL FOR THIS. Run against the OLD
lock (3.13.5) in the same PHP 8.4 container it reported
"No security vulnerability advisories found", EXIT=0 — a false negative from a
stale local advisory cache, which looks exactly like a fixed repository. The
positive control that actually discriminates is the Packagist advisories API
(above), and CI itself.

* test(token): pin the grant_type binding regression

The coverage ratchet caught that the previous commit adds 5 statements to
`lib/` with nothing exercising them:

    Coverage current:    55.77%  (6842/12269 statements)
    Coverage merge base: 55.79%  (6842/12264 statements)
    FAIL: coverage dropped by 0.02% against the merge base.

It is right to complain. A bug this quiet deserves a regression test more than
most: the ONE negative test that existed asserted `unsupported_grant_type`,
which is exactly what a wholly broken endpoint returns for ANY input, so it
stayed green while no client could obtain a token at all.

These tests pin the DISCRIMINATING cases instead — the snake_case spelling must
be ACCEPTED, and a request well-formed apart from a missing/malformed assertion
must fall through to `invalid_request` / `invalid_grant` rather than being
rejected as an unsupported grant. A negative control (`grant_type=password`
is still rejected) sits alongside so the suite cannot pass vacuously.

Proof the tests are load-bearing — same 7 tests, controller swapped:

    against origin/development (buggy):  Tests: 7, Failures: 4
      testSnakeCaseGrantTypeIsAccepted
      testSnakeCaseGrantTypeReachesTheAssertionPath
      testCanonicalSpellingTakesPrecedenceOverTheFallback
      testValidAssertionReturnsTheTokenPayload
    against this branch (fixed):         Tests: 7, Assertions: 14, OK

Full suite 712 -> 719 tests, 2287 -> 2301 assertions, 0 failures.
phpcs EXIT=0.
…-2026-67434) (#172)

* chore(deps): clear all critical + high security advisories

npm (measured on development with `npm audit --package-lock-only`; the
Dependabot alert count is computed on the stale default branch `main` and
is not the number that matters here):

  before  critical 2  high 2  moderate 12  low 5  (total 21)
  after   critical 0  high 0  moderate 10  low 5  (total 15)

- vitest + @vitest/coverage-v8  ^1.6.1 -> ^3.2.7 (kept on the SAME version)
  Clears the CRITICAL "Vitest UI arbitrary file read/execute" (<=3.2.5) and
  drags vite 5.4.x -> 6.4.3, vite-node -> 3.2.4 and esbuild -> 0.25.12
  forward, which clears the `vite` HIGH (<=6.4.2).
  Deliberately 3.2.7 and NOT vitest 4: 3.2.7 is already outside every
  advisory range and is one major less disruptive to the suite.

- @cyclonedx/cyclonedx-npm  ^4.2.1 -> ^6.0.0
  Clears the HIGH "shell injection via unsanitised --workspace" (2.1.0-4.2.1).

composer:

- squizlabs/php_codesniffer 3.13.5 -> 3.13.6 (composer.lock only; the
  ^3.9 constraint in composer.json already allowed it).
  CVE-2026-67434 / GHSA-hmqg-cxww-wqhq, OS command injection, reported
  2026-08-05, affects <3.13.6. This advisory landed after the last
  measurement of this repo, so `composer audit` was clean yesterday and
  is not clean today.

NOT bumped, on purpose: the 10 remaining moderate and 5 low npm advisories
all sit in transitive webpack/eslint tooling with no fix that does not
force a major on a build-critical package. They are not in the
critical/high scope of this sweep.

* test(vitest): give the WebCrypto specs timeout headroom on CI

`quality / Frontend Tests (unit)` failed on this branch with
"Test timed out in 5000ms" — vitest's default `testTimeout`.

This is NOT a broken test, and it is not purely the vitest bump either.
Both halves matter:

1. The headroom was ALREADY gone at vitest 1.6.1. Run 31083918823 on
   `development` failed `emergencyEnvelope.spec.js > builds an envelope
   the grantee can open` with the byte-identical 5000ms timeout, while
   the runs either side of it passed.

2. The 1.6.1 -> 3.2.7 bump then removed what little was left. Measured on
   the CI runner, same job, same repo:

                             vitest 1.6.1     vitest 3.2.7
     attachment-crypto.spec      2604ms           5391ms
     import.spec                 3937ms           TIMED OUT
     total `tests` time          29.08s           35.66s

   Likely mechanism: vitest 2 changed the default worker pool from
   `threads` to `forks` — more per-worker overhead and more contention
   for two cores on CPU-bound crypto.

The tell that this is a marginal timeout and not one bad test is that
WHICH spec tips over MOVES between runs: attempt 1 failed
emergencyEnvelope, the rerun failed import.spec and let
emergencyEnvelope through at 1943ms.

These specs do real WebCrypto (RSA keygen, envelope wrap/unwrap, backup
round-trips) instead of mocking it, which is the right call for a
secrets manager — so the fix is headroom, not weaker tests.

20s is well clear of the slowest observed spec while still failing fast
on a genuine hang; the whole suite runs in ~30s. No assertion changes.

Positive control that the setting is actually wired, not just present:
a probe spec awaiting 8000ms was run twice against this same config —
PASSES with `testTimeout: 20000` (rc 0), FAILS with a CLI
`--testTimeout=5000` override (rc 1, "Test timed out in 5000ms"). The
two arms differ only in the timeout, so the value is demonstrably what
controls the outcome. Probe deleted afterwards.

Local re-verification: test:unit rc 0 (69 files / 425 tests),
test:coverage rc 0 (lines 53.79%), coverage-ratchet rc 0, lint rc 0
(0 errors).
…es (#174)

axe-core sat in `dependencies`, declaring an accessibility *testing* library
as an application runtime dependency.

Measured: nothing under src/ imports axe-core, and the built production bundle
does not contain it — 0 hits for `axe-core`, `axe.run` and axe own signature
strings (`aria-allowed-attr`, `color-contrast`) across js/, while those strings
are present in node_modules/axe-core/axe.min.js (positive control).

What put it in every app manifest is @conduction/nextcloud-vue, which declares
axe-core as an OPTIONAL peerDependency. nc-vue does use it, but only in
`src/testing/a11y.js` — a testing helper never imported from `src/index.js`, so
it never reaches an app bundle. nc-vue own file header states axe-core "is a
devDependency" and that consumers wanting the a11y assertion "add `axe-core` to
their OWN devDependencies". This change follows that instruction.

Not a bundle-size fix; the bundle is byte-identical. It stops a test dependency
being declared as production surface (SBOM, `npm ci --omit=dev`, advisory
triage). An optional peer is satisfied by a devDependency, so nothing breaks.

Verified: npm ci + production webpack build both exit 0; CI Frontend Build,
Frontend Tests, eslint, License (npm) and Security (npm) all pass. The phpmd and
test:l10n failures are pre-existing on unrelated baseline branches.
…er had (#176)

`quality / Frontend Check (test:l10n)` has been red on development:

    l10n-check: FAIL — 352 translation key(s) used in source but MISSING
    from l10n/en.json

Every one is a string the UI already renders through the translate helper
with no entry to translate it against, so it falls through as the raw key.
Run with the extractor the check itself recommends:
`node tests/l10n/check-l10n.js --write`.

This is extraction, not translation. In this file the key IS the English
source, so all 352 added entries have value === key — verified
programmatically, along with zero changes to any pre-existing entry. The diff
touches l10n/en.json and nothing else.

Deliberately NOT touching the other locales. `test:l10n` is the only l10n
script this repo runs in CI (`frontend-checks: ["check:manifest",
"test:l10n"]`) and it checks source coverage only; parity across the
translated locales lives in a separate `check-l10n-parity.js` that CI does
not invoke. Populating 35 locales here would mean inventing translations I
cannot verify — that belongs to the translation pipeline, not to this commit.

Verified: 555 -> 907 keys, `test:l10n` exits 0.
The lock screen was reached via an App.vue created() redirect that ran
after `await initializeStores()`. By the time it fired, vue-router had
already resolved the route and CnPageRenderer had mounted the target
page, so that page's mounted() had issued its fetches — SecretList calls
fetchSecrets() unconditionally. Secret name/url/folder placement are
plaintext server-side by design (searchable for their owner), so the
vault inventory painted and hit the wire before the lock screen replaced
it.

Replace the lifecycle redirect with a synchronous router beforeEach
guard, so a locked vault never resolves a protected route and no page
component is instantiated. Gating navigation behind an awaited settings
request is what opened the window in the first place, so the guard takes
no async dependency. vue-router 4 still supports the (to, from, next)
guard signature; it is kept over the newer return-a-location idiom so
the redirect and the pass-through read identically.

The isLocked watcher stays, narrowed to the mid-session eviction path
(session timeout, "Lock vault" menu entry) where no navigation occurs
and the guard therefore cannot run.

Recipient-facing token routes (SecretRequestFill, LinkShareAccess,
EphemeralSendAccess) stay outside the gate deliberately: those
recipients are frequently not the vault owner and may hold no suite at
all, and the routes carry token-scoped server-side authorisation
instead.

The existing e2e "zero-knowledge gate" test passed throughout the bug —
it waits up to 20s for the lock heading, asserting only the eventual
state, and its path-form URLs never deep-linked into SecretList under
hash mode. Add a test asserting the invariant on the wire instead: a
locked vault requests no secret-bearing endpoint at all.

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
)

The 32 floor was raised on the premise that nothing tested below it. That is
false here: this repo's own CI runs stable31, and min-version is enforced at
install time, so occ app:enable refuses on 31 and the e2e seed fails with
"is not installed or enabled".

The original reason for a 32 floor no longer holds either. It came from
openregister implementing OCP\ContextChat\IContentProvider, an interface
absent before NC 32. openregister#2372 removed every eager reference to that
class, so it is only loaded inside interface_exists() guards and the header is
never read on an older server. openregister#2380 restored its own 28 floor on
that evidence.
eol-last, caught by eslint after the port commit.

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured 55.79% on two independent development push runs (31052650976 and
31074210361); .coverage-baseline said 55.78, so Coverage Baseline Check failed
on the non-empty git diff left by --update-baseline.

Raises the baseline to the measured value, as the job's own error text asks.
No threshold weakened, no waiver.
#167)

`Frontend Check / test:l10n` has been red on development: 829 distinct
literal keys are used by t()/n() calls under src/, but l10n/en.json held
only 555 of them. 352 translatable strings were shipping with no English
source entry at all — nothing for a translator to pick up, and the
Nextcloud extraction contract broken for every one of them.

Fixed with the repo's own sanctioned extraction step,
`npm run test:l10n:write` (tests/l10n/check-l10n.js --write), which merges
each missing used key in as `"<source>": "<source>"` — English source ===
key, the Nextcloud convention, so for en.json the value legitimately IS
the key. The script appends and sorts only among the new keys, so the
diff is provably additive: verified 0 pre-existing keys lost, 0 existing
values changed, the original 555-key order preserved as an exact prefix,
and all 352 added entries value === key.

Reviewed key by key for extraction artefacts (a variable name, a CSS
class, a debug string, an empty string, a non-user-facing string) — there
are none. The 63 single-token additions were each checked at their call
site and are all real UI text: `<th>` table headers (Accessor, Count,
Dropped, Failures, Result), `<dt>` terms (Site, Number, Value,
Transports), button labels (Re-parse, Re-issue, Renew…, Offboard),
status words rendered into the DOM (yes/no/never/disabled/handled/
snoozed/OPEN) and status-label maps (Granted, Approved, Requested).
No call site needed correcting.

All three n('doriath', singular, plural) pairs landed with BOTH forms and
`%n` preserved verbatim:
  %n item / %n items cannot be represented in CXF and will be skipped.
  %n secret copy still needs / %n secret copies still need to be …
  %n secret was / %n secrets were skipped because the successor …

Proof the gate can still fail: removing "{count} selected" from en.json
turns `npm run test:l10n` RED (exit 1) naming that key and its call site
src/views/SecretList.vue:141; restoring it returns exit 0 / 907 keys.

Scope: en.json only, which is all this gate reads — doriath's
check-l10n.js does not require check-l10n-parity.js. The other 35 locales
are NOT touched and a translation backlog remains (nl 494, all others 335,
against en 907). That is a translator's job, not this fix's.
…gression ratchet (#181)

`tests/l10n/check-l10n-parity.js` shipped in 48b8186 — commit message
"i18n: add 35 European locale translations + European parity gate" — and has
never been invoked. `grep -rn check-l10n-parity` matched only the script's own
docblock, and `git log -S "check-l10n-parity.js" -- tests/l10n/check-l10n.js`
is empty on every branch, so it was never wired rather than unwired later.

`Frontend Check (test:l10n)` has therefore been reporting success over 20,433
missing translations across 36 locales (nl 413; each of the other 35 at 572 of
907). That is the falsely-GREEN shape of a dead gate. See doriath#180.

What changes:

* check-l10n.js now `require`s the parity script once the English source set is
  complete — the three-line seam larpingapp already had and this repo lacked.
* The parity script gains two enforcement tiers:
  - L10N_PARITY_ENFORCED — locales that must be at exact full parity.
  - tests/l10n/parity-ratchet.json — a per-locale upper bound on the missing
    count for everything else. Exceeding it fails.
* Coverage is printed on EVERY run: the required count, the fully-enforced set,
  the no-regression-only count, and the outstanding debt (20,433 today). A gate
  that caps its own coverage silently reads as "covered everything".

ENFORCED is empty today, and that is a measurement rather than a shrug: no
locale is at full parity (nl, the closest, is 413 short), so nothing can be
fully enforced without failing every build. The script now REFUSES to start
(exit 2) if L10N_PARITY_ENFORCED names a locale outside the required set — the
first draft defaulted to `en`, which looks like enforcement but enforces
nothing, because `en` is the source language and is not a member of REQUIRED.

This does not clear the debt and does not claim to. It converts a check that
measured nothing into one that cannot let the debt grow. #180 stays open with
the 20,433 figure until the locales are actually translated. No locale file was
padded with English to move a number.

Proven able to fail:
* delete "Import" from l10n/nl.json -> FAIL naming `nl` and `missing: "Import"`.
* add an untranslated key to l10n/en.json -> FAIL on all 36 locales.
* same mutation against the PRE-FIX check-l10n.js -> EXIT 0, green. That A/B is
  the dead gate itself.
* L10N_PARITY_ENFORCED=nl -> FAIL [ENFORCED] nl 413 missing; with nl completed
  -> pass. Unmodified tree -> exit 0.

Refs #180
…ken root lockfile (#183)

#175 bundles two unrelated things. The 8 advisories it names are all in
docs/package-lock.json, and that half is sound. It ALSO regenerates the ROOT
package-lock.json, and that half does not install.

The root change removes 58 packages and adds none — every @esbuild/* platform
binary, @nextcloud/vue's nested vite, and the whole rolldown / lightningcss
toolchain — and contains ZERO version bumps, so it carries none of the security
value. Under the npm this project actually supports it is fatal:

  npm error code EUSAGE
  npm error `npm ci` can only install packages when your package.json and
  npm error package-lock.json are in sync.
  npm error Missing: vite@8.2.1 from lock file
  npm error Missing: esbuild@0.28.1 from lock file   (+56 more)

That is why #175 fails 8 checks that development passes: Frontend Build,
Frontend Tests, both Frontend Checks, Vue Quality, License (npm), Security
(npm) and lint-check. Every one of them is downstream of `npm ci`.

Measured, one variable at a time, with npm pinned to CI's 10.8.2:

  arm A  #175 root lockfile         -> EXIT 1, EUSAGE (reproduces CI exactly)
  arm B  development root lockfile  -> EXIT 0, added 1561 packages
  arm C  #175 docs  lockfile        -> EXIT 0, added 1388 packages

Only the lockfile differs between A and B, so the lockfile is the cause; C
shows the docs half is unaffected.

Worth recording how close this came to being waved through: `npm ci` and a full
`npm run build` BOTH succeeded for me locally against #175's root lockfile, and
the docs build succeeded too. That green was worthless — I was on npm 11.13.0,
while package.json declares `"npm": "^10.0.0"` and CI runs 10.8.2. npm 11
silently re-resolves the pruned entries that npm 10 refuses. A dependency bump
verified on an unsupported toolchain is not verified.

This commit therefore takes docs/package-lock.json from #175 verbatim and
leaves the root lockfile untouched:

  dompurify           3.4.2  -> 3.4.13   (mXSS / DOM-clobbering fixes)
  fast-uri            3.1.2  -> 3.1.5
  launch-editor       2.13.2 -> 2.14.1
  postcss             8.5.14 -> 8.5.26
  qs                  6.15.1 -> 6.15.3
  webpack-dev-server  5.2.4  -> 5.2.6
  websocket-driver    0.7.4  -> 0.7.5
  ws                  7.5.10 -> 7.5.13, 8.20.1 -> 8.21.2

Note for whoever revisits the docs tree: no workflow builds it on a PR into
development. documentation.yml triggers only on push/pull_request to the
`documentation` branch, so these bumps get no CI build here either way. The
docs build was run by hand (npm ci + npm run build, both exit 0, "Generated
static files in build") — on npm 11, so treat that as a smoke test rather than
a guarantee, exactly like the local green above.

Refs #175
…32 (#182)

* fix(a11y): clear five accessibility gates, and raise the NC floor to 32

Full-tree hydra-gates on origin/development reported 19 failing gates. Five of
them were small, real and unambiguous; this clears all five. Measured with the
checkers run directly over the whole tree, because no CI job in this fleet
gives a full-tree verdict — push runs scope to ~1 file and PR runs to the diff.

gate-39 button-name (3) — the close controls in ShareDialog, ShareRequestForm
and ApplicationRegisterDialog contained a bare "×" and nothing else, so they
were announced as "times" or as an unlabelled button. Each gains an aria-label
reusing the existing "Close" key, with the glyph marked aria-hidden.

gate-32 semantic-controls (1) — RecentSecretsWidget rows were <li @click>:
unreachable by keyboard and announced as plain list items. The row is now a
real <button>, which brings role, focus and Enter/Space for free; the list
item keeps the layout. Visual result is unchanged.

gate-44 autocomplete-attr (2) — the two ephemeral-send password fields had no
autocomplete. Deliberately NOT current-password: neither is the visitor's own
credential. The unlock field is autocomplete="off" (a one-shot out-of-band
passphrase, useless to save); the authoring field is "new-password" so a
manager offers to generate rather than autofill the account password.

gate-45 prefers-reduced-motion (2) — PasswordStrengthMeter and TotpDisplay
animate without a reduced-motion fallback (WCAG 2.2 AA 2.3.3). Both keep every
state change and drop only the tween, so no information is carried by motion
alone; TotpDisplay's numeric countdown already carries the same cue.

gate-34 window-confirm (1) — ApplicationDetail confirmed a cascading delete
with a native browser prompt. That prompt cannot be themed or translated
beyond its message, and is suppressed outright in some embedded/kiosk
contexts, where it returns false and the delete silently never happens. It is
now ApplicationDeleteDialog, in its own file under src/dialogs/ per ADR-004.

Also: <nextcloud min-version> 31 -> 32 (fleet-wide floor, PO decision), so
PHP 8.3 is guaranteed by the platform. 31 was never deliverable — the CI
matrix already tests only stable32, because OpenRegister declares a floor of
32 itself, so the App Store advertised a range no test leg covered and no
dependency could satisfy. max-version stays 34, the fleet-wide value
everywhere except openconnector (35).

Evidence:
* full-tree gates 19 failing -> 14, with exactly these five flipped and no
  other gate changing state in either direction.
* each fix proven able to fail INDIVIDUALLY: reverting just that file returns
  its gate to FAIL with the original finding count (39: 3, 32: 1, 44: 2,
  45: 1-of-2, 34: 1), and restoring returns it to PASS.
* two regressions I introduced were caught by re-running and fixed: the
  delete dialog was inline at first (gate-13 5 -> 6) and is now extracted, and
  the two new methods needed @SPEC anchors (gate-16). Both are back to
  baseline.
* 425 vitest tests pass; eslint clean (exit 0 read directly, not through a
  pipe); test:l10n still green and adds ZERO new translation keys — 829 used,
  907 in en.json, unchanged — so this does not move the doriath#180 debt.

Not touched, with reasons: gate-7 is anti-correlated with what it checks
(.github#160) and its 3 findings here are a CSPRNG capability-token relay plus
a deliberately world-readable password-policy floor; gate-9's single finding
resolves its credential via IUserSession::getUser(), which the rule does not
recognise; gate-14's 5 findings are all provided by
OCA\OpenRegister\AppHost\Routes::standard() at appinfo/routes.php:22; gate-40
measured 58% false-positive fleet-wide. gate-22 and gate-53 fail only when ajv
is unresolvable and pass under a real npm ci.

* fix(a11y): label the send-link field and repoint two dead @SPEC anchors

The PR-scoped Hydra Gates run failed on this branch with 2 gates that the
full-tree run on development also reports. Both are PRE-EXISTING findings in
files this PR touches for the autocomplete fix — diff-scoping pulls a touched
file's existing debt into scope, which is working as designed.

Proven pre-existing against the BASE tree with the files in scope, not by
comparing counts: the full-scope run at origin/development emits the identical
three lines.

  gate-46  src/views/EphemeralSendAccess.vue  -> #requirement-anonymous-access
           src/modals/NewSendDialog.vue       -> #requirement-create-and-store-ciphertext-only
  gate-40  src/modals/NewSendDialog.vue       -> <input :value="link" readonly …>

gate-46: both anchors point into openspec/changes/ephemeral-send/…, and that
file contains NO `### Requirement:` headings at all, so neither anchor could
ever have resolved. The canonical openspec/specs/ephemeral-send/spec.md does
have them, so both now target the canonical spec — which is where an @SPEC
should point in the first place; a change dir is not a spec's permanent home.

  #requirement-anonymous-recipient-access-with-no-account
  #requirement-create-a-standalone-ephemeral-send

gate-40: the generated-link field is a read-only display sitting directly
under the note card that explains it, so a visible <label> would be redundant
to sighted users while a screen reader still needs the control named. Uses
aria-label with the EXISTING "Link" key — this branch still adds zero new
translation keys, so it does not move the #180 debt.

Verified: diff-scoped gates now 58 of 58 applicable gates ran, ZERO failing,
over a SCOPE-FILE-COUNT of 11 — a real scope, not a green over zero files.
Can-fail control: removing the aria-label again returns gate-40 to FAIL, so
the scoped run is genuinely sensitive to this file. 425 vitest tests pass,
eslint exit 0, test:l10n green.
…ng (#186)

Follow-up to #181, closing a hole I left in it.

#181 wired up a gate that had never run. It did not stop that gate from
reporting OK on an empty comparison — which is the same defect one level up,
and would have been the original doriath#180 finding reintroduced in a form
that looks like a fix.

Three inputs made the MERGED version print success while comparing nothing.
Measured against origin/development's copy, not asserted:

  L10N_REQUIRED_LOCALES=" "        OLD exit 0: "0 required locales …
                                             OK — every required locale is at
                                             full parity"
  no l10n/en.json at all           OLD exit 0: "no en.js / en.json source set
                                             found — nothing to check"
  en.json present but ZERO keys    OLD exit 0: "OK — every required locale is
                                             at full parity"

The first is the worst: a single env var set to a space silently turned the
whole gate green while still printing a pass line. I introduced that.

All three now exit 2 and name the empty input. Every run also prints the work
actually performed, so a zero is visible rather than inferred:

  l10n-parity: WORK DONE — 36 locale file(s) compared, 32652 key comparison(s).
               A zero here means this gate measured nothing.

Proven, each input run against BOTH copies:

  input                     OLD (merged)   NEW
  empty required set        exit 0 "OK"    exit 2 "required-locale set is EMPTY"
  no source set             exit 0 "OK"    exit 2 "nothing to compare against"
  source set with 0 keys    exit 0 "OK"    exit 2 "ZERO key comparisons"
  unmodified doriath tree   exit 0         exit 0, 36 files / 32652 comparisons

The last row is the positive control: hardening must not turn a genuinely
clean run red, and it does not.

Context for why this is worth a commit of its own. In the same week: gate-30
reported PASS fleet-wide having matched nothing, writing a 0-BYTE findings log
(.github#213); gates 22 and 53 reported failures that were an unresolvable
`ajv` rather than findings, and once resolved gate-53 surfaced a real
cross-reference defect it had been hiding; and this file had zero callers for
its entire life. Different mechanisms, one family: a check that passes or
fails for an environmental or parsing reason is not measuring the code.

lint: tests/ is outside CI's eslint scope (`"lint": "eslint src"`). The two
added n/no-process-exit reports are the only delta against the base file,
which already carries seven of them.

Refs #180
…187)

* fix(manifest): drop the dead `example` deep link that routes nowhere

The manifest declared a deep link to a page that does not exist:

  {"registerSlug":"doriath","schemaSlug":"example",
   "urlTemplate":"/apps/doriath/#/examples/{uuid}"}

There is no `/examples` route in pages[] — the closest are /secrets/:id and
/applications/:id — so ADR-040's deep-link listener would resolve an
`example` object to a URL the router cannot serve. It is scaffold residue from
b7285f4 ("adopt OpenRegister AppHost engine"), matching the placeholder
`example` schema in lib/Settings/doriath_register.json whose sample values are
literally "My example" / "This is an example". Doriath has no examples feature.

How it stayed hidden is the more interesting half. gate-53 was reporting

  FAIL — ajv not resolvable from …/hydra-gates/scripts/lib — refusing to run
  fail-open

which reads like tooling noise, and gate-22 was failing the same way with
"SCHEMA VALIDATION DID NOT HAPPEN". Both are WIRING failures, not findings.
Resolve ajv (NODE_PATH, or npm ci) and the picture changes completely:
gate-22 goes GREEN — it was never a real failure — and gate-53 stops
degrading and surfaces this, a genuine cross-reference defect it had been
sitting on. A gate that cannot run is not a gate that found nothing.

Proven able to fail, three ways:
  dead deepLink restored            -> FAIL 1 cross-reference failure
  fix reapplied                     -> PASS
  a DIFFERENT bad deepLink planted  -> FAIL 2 cross-reference failures

The third arm matters: it shows the gate validates route prefixes generally
rather than pattern-matching this one entry, so the PASS above is a real
verdict and not an artefact of removing the only thing it knew how to check.

Also verified: `npm run check:manifest` PASS (Ajv 0 errors, consistency 0
issues) with `deepLinks: []`, so an empty array is valid and the key does not
need removing. 425 vitest tests pass; test:l10n green.

* fix(manifest): rename the user-settings entry so it stops rendering "Settings > Settings"

The settings foldout contained an entry whose own label was "Settings", so the
menu read Settings > Settings (ADR-079 D4). Renamed to "Personal settings",
which also distinguishes it from the admin settings surface.

This was surfaced by gate-63, and how it stayed hidden is the point. gate-63
reports PASS on a full-tree run — and its findings log on that run says:

  No changed manifest / menu-layout — gate skipped (ADR-020 diff scoping).

The gate SKIPPED and then reported PASS. It only judges the manifest when the
manifest is in the diff, so the only run that ever evaluates it is a PR that
happens to touch the file. A full-tree audit — the thing you run precisely to
find inherited debt — is the one mode where this gate is guaranteed to say
nothing and call it success.

That is the third falsely-green mechanism found in this repo today, after the
l10n parity gate having no callers at all (doriath#180) and gate-30 reporting
PASS over a 0-byte log (.github#213). Reported as .github#238.

Proven able to fail: revert ONLY the label back to "Settings" and gate-63
returns FAIL — 1 settings-placement violation (ADR-079); restore and it
passes. Diff-scoped run is now 0 failing gates over SCOPE-FILE-COUNT 1.

Also verified: check:manifest PASS (Ajv 0 errors, consistency 0 issues);
425 vitest tests pass; test:l10n green. manifest.json is not scanned by
check-l10n, so this label change adds no translation debt.
… cap (#188)

* fix(e2e): retain-on-failure traces + a globalTimeout under the 45m CI cap

Fleet-wide Playwright instrument sweep, ConductionNL/.github#188. Neither
change can alter a verdict; both change whether you can see why a verdict
happened.

`trace: 'on-first-retry'` only writes a trace when a retry actually happens, which makes the trace artifact a function of `retries`. `retain-on-failure` captures every test, keeps only the failures, and does not depend on the retry count.

No repo in the fleet set `globalTimeout`. The shared quality.yml Playwright
job is `timeout-minutes: 45`, and a job cancelled by that cap produces no
verdict and no artifacts: the trace upload is `if: failure()` and the report
upload is `if: always()`, and neither runs on a cancelled job, while
`gh pr checks` still renders it as "fail". Runs cancelled at ~45m16s have
been observed in this fleet. Measured overhead in that job before the
`Run Playwright tests` step starts is 2.0-2.4 min, so 38m leaves ~7 min of
margin while guaranteeing a tally and its artifacts.

* fix(e2e): apply the same fix to the config CI actually loads

The shared quality.yml resolves its config as
`${playwright-test-path}/playwright.config.ts` and only falls back to the
app-root `playwright.config.ts` when that file is absent (quality.yml
~L2218). This repo ships tests/e2e/playwright.config.ts, so THAT is the
file every CI run has been using — the app-root config fixed in the previous
commit is the one developers load by hand, not the one the gate reads.

Applies the identical `retain-on-failure` + `globalTimeout: 38 * 60_000`
change here. ConductionNL/.github#188.
rubenvdlinde and others added 29 commits August 19, 2026 21:27
…andards/phpcsextra-1.5.1

build(deps-dev): bump phpcsstandards/phpcsextra from 1.5.0 to 1.5.1
…ics/phpmetrics-2.11.0

build(deps-dev): bump phpmetrics/phpmetrics from 2.9.1 to 2.11.0
…oken

v1.8.0 shipped an ObjectServiceInterface WITHOUT patchObject() and with
updateObject() still summarised as "Apply a partial update" — the wording
that sent a consumer down the erasing path. The correction landed on main
two days after the tag; every app has been pinned to the broken copy since.

It is not confined to this repo: hydra-gates claims OCA\OpenRegister\Contract\
in its composer autoload, a LONGER psr-4 prefix than openregister's own
OCA\OpenRegister\ -> lib/, so the gate package wins. Nine repos vendor it, so
under OC_App::loadApps() whichever app registers first defines the contract
for the whole instance. Measured on a running instance, softwarecatalog's
vendor directory was supplying openregister's interface, and updating
openregister ALONE did not change the winner — which is why this lands
across the fleet rather than in one repo.
chore(deps): take hydra-gates v1.8.1 — the contract v1.8.0 shipped broken
All three reproduce locally and all three are this branch's own, which is why they
could not be fixed centrally: `npm run lint` exits 0 on development and 2 here.

**`npm run format`** — CI runs prettier over the WHOLE repo
(`**/*.{js,ts,vue,css,scss}`), not the `src/` and `tests/` subsets I had been
checking, which is why my local sweeps reported clean. One file was unformatted:
tests/views/SecretRequestFill.spec.js. `format:fix` touched only that file.

**`npm run lint`** — exited 2 while printing "0 errors", on "There are suppressions
left that do not occur anymore". Pruning removed exactly one entry:
`src/App.vue @nextcloud/l10n-enforce-ellipsis`. This branch's edits to App.vue
eliminated that violation, so the suppression went stale — which is also why
pruning on development would have been wrong: nothing is stale there, and the entry
is still needed.

**`npm run test:l10n`** — "Revoking…" (src/App.vue:194) was used in source but
missing from en.json. A pre-existing gap that predates this branch; it surfaced here
because this is where App.vue changed. Added with the same 36 translations used on
the later branches, so merging up is a no-op rather than a conflict. Mixed-script
scan clean.

Worth stating plainly: I reported "eslint 0 errors" and "prettier clean" as green in
earlier commit messages on this branch. Both commands actually failed — one by exit
code while printing a reassuring summary line, the other because I checked a
narrower path set than CI does. Exit codes from here on, not summary text.

NOT fixed here, because it is not a code problem: the four PHPUnit matrix jobs fail
on `HTTP/2 504` fetching php-fig/cache while composer-installing the sibling
openregister app, and the job correctly refuses to continue rather than report that
app's missing classes as ours. Same signature on all four legs. Needs a re-run.

Sweep: gates 49/49 applicable, PHPUnit passing, vitest 583, format/lint/test:l10n
and the l10n parity ratchet all exit 0.

Assisted-by: ClaudeCode:claude-opus-5
…y-lifecycle' into feature/268/secret-request-expiry-lifecycle
…ores

chore(openspec): archive two finished changes and clear the debt behind them
…re/271/admin-application-request-visibility

# Conflicts:
#	l10n/be.json
#	l10n/bg.json
#	l10n/bs.json
#	l10n/ca.json
#	l10n/cs.json
#	l10n/da.json
#	l10n/de.json
#	l10n/el.json
#	l10n/en.json
#	l10n/es.json
#	l10n/et.json
#	l10n/fi.json
#	l10n/fr.json
#	l10n/ga.json
#	l10n/hr.json
#	l10n/hu.json
#	l10n/is.json
#	l10n/it.json
#	l10n/lb.json
#	l10n/lt.json
#	l10n/lv.json
#	l10n/mk.json
#	l10n/mt.json
#	l10n/nb.json
#	l10n/nl.json
#	l10n/pl.json
#	l10n/pt.json
#	l10n/rm.json
#	l10n/ro.json
#	l10n/ru.json
#	l10n/sk.json
#	l10n/sl.json
#	l10n/sq.json
#	l10n/sr.json
#	l10n/sv.json
#	l10n/tr.json
#	l10n/uk.json
Merged feature/268 in (its l10n fix propagated — `test:l10n` now passes here) and
fixed what is this branch's own.

**`npm run format`** — the four files CI named, all touched by this branch:
SecretRequestList.vue, store/modules/secretRequest.js, and the SecretRequestList
and ApplicationRequestRevokeDialog specs. The fifth CI listed arrived via the 268
merge.

**`npm run lint`** — one stale suppression removed:
`src/components/secretRequest/SecretRequestList.vue eqeqeq {count: 2}`. This branch
replaced those `== null` comparisons with `typeof` checks, so the suppression no
longer had anything to suppress. Same shape as 268's, different entry, which is why
each branch has to prune its own.

**The l10n merge needed care.** Both branches added "Revoking…" to all 37 locale
files, so every one conflicted. Resolved by merging the translation maps rather than
by hand — and one key genuinely differed: the Estonian
"Create a password-protected link…" string, where this branch has the fix for a
Cyrillic т inside `kasutuslimiit` and 268 still had the typo. This branch's version
kept.

A process note worth recording, because it nearly went into the repo: my first
attempt at that resolution staged all 37 files still containing conflict markers.
The script that was supposed to merge them found zero conflicted files, printed
"merged 0", and I ran `git add` anyway without checking its output — 37 invalid JSON
files staged. Nothing was committed; HEAD had not moved, so `git restore` put it
back and the merge was redone with the stage read PROVEN to work first and a
marker/JSON validation before staging. Verify the tool worked before trusting what
it produced.

Sweep: gates 49/49 applicable, PHPUnit passing, vitest 594, phpcs 0 errors,
format/lint/test:l10n all exit 0.

Still failing on #286 and NOT addressed here: the coverage ratchet
(−0.84% on this branch's own touched files). That needs tests, not formatting.

Assisted-by: ClaudeCode:claude-opus-5
…expiry-lifecycle

feat(secret-requests): make an expiry something that is acted on, not only checked
…s pointing at

#286's PHPUnit job was not failing on a test. It was the coverage ratchet:
"coverage of the files this change touches dropped by 0.84%" — 81.27% against a
82.11% base. The honest reading is that I added error-handling code and never
exercised it, and the ratchet noticed.

Nine tests, all on paths I wrote and left uncovered:

**ApplicationRequestAdminService** — an empty applicationId refused before any query
runs; a non-pending request refused rather than silently "revoked"; and the two
fail-soft rungs of the placeholder cleanup that nothing touched: the linked Secret
already gone (revoke still succeeds, nothing to clean up) and the delete itself
throwing (an orphan empty Secret is untidy, a revoke the administrator believes
failed is worse — the link is already dead and they will go hunting for another way
to kill it).

**ApplicationRequestAdminController** — `statusFor()`'s fallback, which existed
precisely because `InvalidArgumentException` defaults its code to 0 and a
JSONResponse with status 0 is uninterpretable, and was never exercised; the
service's refusal code surviving on the listing path; an unexpected failure
answering 500 while NOT leaking the exception text; and a revoke of an unknown id
answering 404 rather than 500, since it arrives as a mapper lookup exception rather
than an InvalidArgumentException.

Both classes are now at 100% (52/52 and 45/45). Touched-file coverage: 83.46%
(535/641) against the 82.11% base.

Two measurement notes, because the first number I got was wrong.

The initial re-measure said 83.46% off 641 statements where the failing run had 801,
with six files silently absent from the report — an incomplete clover file, not an
improvement. Re-run with output visible and every touched file confirmed present
before believing it. Coverage also needs XDEBUG_MODE=coverage under Xdebug 3;
without it phpunit writes no clover at all and says nothing about why.

The scope legitimately shrank from 12 files to 9 in the meantime: #282 merged into
development, so feature/268's files are now part of the shared base rather than this
branch's diff.

Still 0% and untouched by this: lib/Db/SecretRequestMapper.php (0/64). Mapper
coverage needs a database rather than mocks, so it is a different kind of work and
does not belong in a CI fix.

Assisted-by: ClaudeCode:claude-opus-5
…tart

A before-starting hook (docker/nextcloud/enable-apps.sh) runs as
www-data on every container start, after install/upgrade, and enables
openregister and doriath — replacing the manual occ app:enable steps
after docker compose up. Mounted as a docker-entrypoint hook rather
than replacing the image entrypoint, which broke startup before.
…oads

Nextcloud serves JS-side translations exclusively from l10n/<locale>.js
(OC.L10N.register); this repo shipped only the backend l10n/<locale>.json
files, so t('doriath', ...) fell back to English in the browser for
every language regardless of the language setting. Adds a generator
(tests/l10n/generate-js-l10n.js, run it after changing the .json files),
the 37 generated .js files, and the frontend set's parity-ratchet
baseline so the l10n gates hold the new file set to no-regression.
Adds the suite-check strings (Checking your vault, the fail-closed
could-not-determine error, Try again) and the password-mismatch and
passwords-match messages to every locale, each phrased with the vault
vocabulary that locale already uses. The matching frontend .js files
landed with the generator commit; this adds the backend .json side.
…feedback

The lock screen now covers the app as a login-style overlay (app
navigation hidden, themed primary background) instead of rendering
beside an interactive sidebar. The setup-vs-unlock decision waits for
the suite check and fails closed: the create-a-new-suite form can no
longer flash before the check resolves, nor render when it fails;
offline falls through to the snapshot-only unlock form. Error placement
follows the Nextcloud login screen: systemic errors banner above icon
and title, wrong-password and debounced password-mismatch feedback
inline under the field (aria-described, layout-stable, with a tooltip
on the disabled submit as progressive enhancement). Leaving the app for
another Nextcloud app no longer flashes the unlock form mid-transition:
the beforeunload key clear stays, only the redirect on a dying page is
skipped, with a timer fail-safe when the unload never happens.
Two user-visible defects are fixed by this bump.

TWO AI-COMPANION HEXES ON EVERY PAGE. The companion singleton landed in
2.7.0. Below that the host app's own companion never stands down, so any
page of this app rendered a second hex 8px from hermiq's — measured on a
running instance: openconnector (2.7.1) showed ONE, openbuild (2.6.3)
showed TWO, both visible at 52x60, from two separate mounts.

THE DETAIL PAGE RECLOSED ITS SIDEBAR WHILE HYDRATING. CnDetailPage set
sidebarSeeded and never read it, so 'open' was re-applied on every sync
and each reactive change during hydration reset it to the prop default.
Fixed in nextcloud-vue#711; that is the cause behind openbuild#268 and,
on the evidence, #188.

Lockfile only — the existing caret already allowed this. Three-line diff
(version, resolved, integrity).
The eslint rule @nextcloud/l10n-enforce-ellipsis flags triple dots in
translated strings. Renames the Setting up/Unlocking/Changing keys to
their ellipsis form in source and in all 37 locale files, converting
the translated values' trailing dots as well, and regenerates the
frontend l10n .js files.
…mption

docs(gate-7): declare the k-anonymity range proxy exempt, with its reason
chore(deps): take @conduction/nextcloud-vue 2.8.2 (was 2.3.0)
Prettier-formats the two hand-formatted lock-screen components and prunes
eslint-suppressions.json entries those edits made unused (ESLint 10 fails
on stale suppressions, which is what broke the eslint and lint-check gates
despite src/ having no rule errors). Adds a prefers-reduced-motion
fallback for the lock screen's feedback fade (hydra gate-45) and @SPEC
tags on the new lock-screen methods (gate-16). Updates the four e2e tests
that asserted the app navigation is visible on the lock screen — hiding
it there is the point of the rework — to assert the nav is hidden while
locked and to unlock (dev master password) before asserting menu
contents. Also fixes an eqeqeq in the l10n generator.
…he right placeholder

Two defects on the expiry path, both raised in review on PR #282.

1. Expiry raced a concurrent fill. `QBMapper::update()` issues
   `WHERE id = ?` with no status guard, so the sweeper's entity — loaded
   a whole batch earlier — overwrote whatever the row had become. A
   recipient who filled a request between the job's SELECT and its write
   had `fulfilled` replaced by `expired`, with `fulfilled_at` left set:
   the requester was told their request had lapsed while the credential
   was already sitting in their vault. The pre-flight status check could
   not catch this; only the write itself can. `transitionIfPending()`
   does the transition as `UPDATE ... WHERE id = ? AND status =
   'pending'` and reports affected rows, and expire() now does nothing at
   all when that returns zero — no placeholder delete, no audit event
   claiming an expiry that never happened.

2. Expiry never cleaned up an application-owned placeholder. It called
   the user-revoke helper, passing the request's `created_by` as the
   owner. For an application request that value is `application:<id>`,
   never a user id, and the helper also required `owner_type === 'user'`
   — so it failed twice over. Expired application placeholders
   accumulated indefinitely, which is the exact outcome the expiry job
   exists to prevent. The comment above the call claimed it passed the
   Secret's owner; it did not, and the mismatch is what hid the bug.

Both cleanup helpers are now one method on a new SecretPlaceholderCleaner,
taking the acting user as a parameter: given, it is a user revoke and the
Secret must be theirs (an authorization boundary, kept intact); null, the
system expired the request and the owner comes off the Secret. Extracting
it was not cosmetic — adding the second caller in place pushed
SecretRequestService to complexity 53 against phpmd's threshold of 50, and
development's baseline is clean, so that was mine to resolve. A third
suppression would have hidden it; the class the code came from is the
request state machine, and whether a Secret may be destroyed is a
different question that now has a name.

Both defects were verified to discriminate by reintroducing them: making
the transition unconditional fails only the concurrent-fill test, and
routing expiry back through the user branch fails only the
application-placeholder test.

phpcs, phpmd, psalm and php-cs-fixer are clean; 1034 tests pass.

Assisted-by: ClaudeCode:claude-opus-5
Brings in the two expiry fixes raised in review on PR #282 (atomic
`transitionIfPending` transition; cleanup that reaches an application-owned
placeholder), and with them development's newer commits.

Two conflicts, both in the expiry-cleanup code the two branches changed for
the same reason:

- `SecretRequestService`: this branch had moved `holdsNoValues()` onto the
  Secret entity because two services need one predicate; the fix branch had
  moved the whole cleanup decision into SecretPlaceholderCleaner. Resolved
  by keeping both moves — the private wrappers are gone, and the cleaner now
  calls `Secret::holdsNoValues()` rather than carrying a second copy of a
  predicate whose duplication is what let this path delete filled secrets in
  the first place.
- `SecretRequestServiceTest`: both sides appended tests at the same point.
  Both kept.

1058 tests pass.

Assisted-by: ClaudeCode:claude-opus-5
Raised in review on PR #286. `revokeForApplication()` read the request,
checked it was pending, then wrote `declined` back with
`QBMapper::update()` — `WHERE id = ?`, no status guard. A recipient
filling the request between the check and the write had `fulfilled`
overwritten with `declined`, and the administrator was then handed an
audit event recording a revoke of a request that had in fact been
answered. The pre-flight check cannot catch this; only the write can.

Now `transitionIfPending()` does the transition conditionally and reports
affected rows, and nothing downstream runs unless it actually happened —
no placeholder delete, no revoke recorded. A lost race returns the same
400 the pre-flight check would have, because it is the same situation
observed a moment later.

The class also carried a third copy of the placeholder-cleanup logic,
differing from the other two only in whose vault it may delete from.
Three near-identical hard-delete paths is the fault class that produced
both defects reported on #282, so the copies are now one
SecretPlaceholderCleaner call and authorization is stated by the caller:
'user' + uid, 'application' + application id, or neither for the system.
The check still runs against the SECRET while the expectation comes from
the CALLER — two checks against different data, so a mismatched pair
cannot destroy a third party's Secret. `secretMapper` and `container`
went with it; the cleaner holds them now.

`adminUid()` is renamed `requireAdminUid()`. It reads as a getter, which
left the authorization hiding behind an assignment — invisible to a
reviewer skimning the method, and to the IDOR gate, which looks for the
guard at the call site. The guard was always there; now it says so.

Verified beyond the unit tests: the conditional SQL was exercised against
the live database — it transitions a pending row once, refuses the second
attempt, and cannot touch a `fulfilled` row, which is the reported defect.
DI resolves the cleaner, both services, the admin controller and the
expiry job. The new race test discriminates: reverting the guard fails
only that test.

1059 tests pass; all 30 hydra gates green (gate-7 included).

Assisted-by: ClaudeCode:claude-opus-5
…quest-visibility' into feature/271/admin-application-request-visibility
feat: fail-closed lock screen, working frontend translations, and a local compose stack
…on-request-visibility

feat(application-mgmt): show an administrator what an application is asking people for
@rubenvdlinde
rubenvdlinde merged commit 6ba1ac1 into beta Aug 20, 2026
10 of 41 checks passed
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.

5 participants