Release: merge development into beta for the fleet-wide beta checkup - #295
Merged
Conversation
…issions Point the wrapper at ConductionNL/.github (the org "Conduction" does not exist), cancel superseded runs per ref, and declare least-privilege permissions (CodeQL actions/missing-workflow-permissions).
…-token/jwt-library (#135) The lockfile pinned roave/security-advisories to a 2026-04-02 commit, freezing its conflict rules — and all protection against CVEs published since — at that date. Refreshing it was blocked by an aliasing artifact: roave conflicts with web-token/jwt-experimental <4.1.7, and web-token/jwt-framework 3.4.10 replaces jwt-experimental at self.version, so the patched 3.4.10 monolith trips a rule whose range was written without the <3.4.10 carve-out the same advisory carries against jwt-framework itself. Every upstream web-token advisory is fixed in 3.4.10, and composer audit --locked reports 0 here — this was never a live vulnerability. Rather than force a major upgrade to ^4, depend directly on the split package we actually use: - "web-token/jwt-framework": "^3" + "web-token/jwt-library": "^3.4.10" Same version, 3.4.10. All 24 distinct Jose\\Component classes used across openregister, doriath and openconnector were checked to resolve inside the jwt-library 3.4.10 dist (with a positive control); all 24 resolve. No usage of Jose\\Bundle, JoseFramework or Jose\\Experimental anywhere in lib/ or tests/. This reduces attack surface: jwt-experimental, the package that genuinely carries an unbounded advisory range, is no longer installed. composer audit --locked: 0 advisories before -> 0 after.
The caller's permissions block is a static ceiling for every job in the called quality.yml — including disabled ones — so it must cover the widest declared grant (journeydoc/update-baseline/features-extract need contents/actions write; the Quality Report comment needs issues/pull-requests write). Also satisfies CodeQL actions/missing-workflow-permissions and cancels superseded runs.
* ci(e2e): enable the shared E2E Tests (Playwright) job
Adds a CI-only Playwright config, an explicit provisioning script, and the
caller wiring needed for ConductionNL/.github's `E2E Tests (Playwright)` job
to run Doriath's 56-test regression suite on a fresh Nextcloud install.
- .github/workflows/code-quality.yml: enable-playwright, playwright-test-path,
playwright-seed-command, plus additional-apps for OpenRegister (doriath
adopts its AppHost engine in lib/AppInfo/Application.php).
- tests/e2e/playwright.config.ts: CI regression config declaring ONLY the
chromium project. The shared workflow passes no --project, so the root
config would also run `visual`, whose PNG baselines cannot byte-match a CI
Linux runner. Report/output land at the app root, where the workflow's
upload-artifact steps look.
- tests/e2e/ci-seed.sh: turns on `debug` and re-runs the repair steps, so
SeedDevelopmentData actually seeds the dev vault the nine workflow specs
unlock; imports the register through OpenRegister's admin API; then verifies
secret types (positive control), the active suite, the seeded secrets and the
register/schema slugs, and gates on the bundle serving as JavaScript.
- tests/e2e/base-url.ts: accept NC_BASE_URL too (the workflow exports it).
- .gitignore: the app-root playwright-report/ and test-results/ the CI config
writes to.
* fix(migration): doriath_ca_certs.is_active must not be BOOLEAN NOT NULL
Nextcloud 31's MigrationService::ensureOracleConstraints() throws for any
new BOOLEAN NOT NULL column on every platform, so 'occ app:enable doriath'
aborted on a fresh NC 31 install with
Column "oc_doriath_ca_certs"."is_active" is type Bool and also NotNull,
so it can not store "false".
i.e. the app could not be installed at all on the minimum server version its
own appinfo/info.xml declares. NC 32 narrowed the same check to Oracle, which
is why newer servers never showed it, and doriath's PHPUnit legs have been
skipped behind a red php-quality job, so nothing had ever installed the app
on 31. Every other boolean column in lib/Migration already uses
notnull => false; this brings the first one into line.
Also pins the E2E / Newman / Journeydoc server (nextcloud-test-refs[0]) to
stable32, because OpenRegister — installed as an additional-app and required
by doriath's AppHost integration — implements OCP\ContextChat\IContentProvider,
which does not exist before NC 32. The PHPUnit matrix still covers stable31.
* ci(e2e): do not assert an OpenRegister register doriath never declares
lib/Settings/doriath_register.json is still the scaffold descriptor: it
carries a single `example` schema and no `components.registers` at all, and
OpenRegister's ImportHandler only creates registers from that key. Report the
register list for diagnosis, assert on the schema slug the config actually
describes.
* fix(appinfo): declare supported databases so the app can install at all
Nextcloud's MigrationService turns on `checkOracle` for any app that declares
no <database> dependency, and then validates every migration against
ensureOracleConstraints(). Doriath's schema does not satisfy those
Oracle-derived identifier limits:
Table name "oc_doriath_certificate_metadata" is too long.
plus the companion rule that rejects a default-named primary key on any table
whose unprefixed name is >= 23 characters, which covers a dozen Doriath tables.
The consequence is not cosmetic: 'occ app:enable doriath' aborted on a fresh
Nextcloud, so the app was not installable from scratch on a stock server. Long-
lived dev instances hid it because the checks only apply to tables that do not
exist yet, and CI never exercised it (the PHPUnit legs are skipped behind a red
php-quality job and the E2E job was not enabled until now).
Declaring pgsql/sqlite/mysql is Nextcloud's own mechanism for this and is
simply true — Doriath has never supported Oracle. openregister and opencatalogi
already ship exactly these three entries.
* fix(offline): the service worker broke every controlled request
Once `clients.claim()` put a page under the offline app-shell worker, every
same-origin `/apps/doriath/` GET it intercepted started failing with
net::ERR_FAILED. Verified from the CI Playwright traces of run 30798827764:
- the first, still-uncontrolled GET of /index.php/apps/doriath/lock returned
200 text/html; …/serviceworker.js was then fetched and claimed the client;
the very next GET of that same URL came back with status -1 / x-unknown.
- the lazily-loaded argon2-browser WASM chunk, requested only after the
worker took control, failed identically — the Share dialog showed
"Loading chunk … failed" and password-protected link shares could not be
created at all.
For a user: a reload, a bookmark, or any second Doriath link lands on the
browser's network-error page, and link sharing is broken. 16 of 56 e2e specs
failed on these two symptoms.
The offline cache is a resilience feature, not a performance one — interposing
on the online path bought nothing and broke the app. The worker now:
- precaches the shell AND the doriath JS/CSS the shell references, at install
time, which is where the "app shell loads offline" requirement is actually
satisfied (nothing was precached before — entries only appeared as a side
effect of serving live traffic, i.e. the broken path);
- returns without calling respondWith() while the browser is online, making
it a no-op and leaving the online path byte-identical to having no worker;
- serves from cache only when the browser reports itself offline, with a
navigation falling back to the one precached shell document and a cache
miss still falling through to the network.
No spec scenario changes: offline shell load, the no-secret-material rule and
the version-keyed cache eviction all still hold.
* fix(ui): NcSelect dropdowns rendered behind NcDialog, making them unclickable
NcSelect defaults to appendToBody, so vue-select teleports its dropdown to
<body> and nc-vue tags it .vs__dropdown-menu--floating — a class that carries
position: absolute and NO z-index (@nextcloud/vue 9.9.0), while NcModal (which
NcDialog is built on) sits at z-index 9998/10001. Any select opened from inside
a dialog is therefore painted behind that dialog.
It fails silently and reads as a dead control: the options are in the DOM,
visible and enabled, but every click lands on the dialog body. On the
Move-secret dialog's "Destination folder" select, picking a folder was simply
impossible — Playwright retried the click 100 times over 60 seconds and each
attempt hit <div class="dialog"> instead of the option.
Raises the teleported menu above the modal layer. The real fix belongs upstream
in nc-vue's NcSelect stylesheet. Per-select :append-to-body="false" was
rejected: .dialog__content is overflow:auto, so an inline menu is clipped by
the dialog instead of overflowing it.
Also fixes the 3 pre-existing stylelint string-quotes errors in this file.
* fix(ui): raise the NcSelect dropdown above NcDialog with sufficient specificity
The previous attempt used .vs__dropdown-menu--floating { z-index: 10002 },
which is 0-1-0 — the same specificity as vue-select's own
.vs__dropdown-menu { z-index: var(--vs-dropdown-z-index) } — and vue-select's
stylesheet is injected later (it rides the NcSelect chunk; this file is
imported at the top of main.js), so source order handed the win to vue-select
and the dropdown stayed behind the dialog.
Root cause, precisely: nc-vue 9.9.0 sets --vs-dropdown-z-index: 9999 on body
while its own NcModal wrapper sits at 10001, so every NcSelect opened inside an
NcDialog is unreachable. Overriding :root does not help either — the variable
is declared on body itself, and an element's own declaration beats a value
inherited from :root.
Uses a two-class selector (0-2-0) plus the body > child form, which win
regardless of injection order.
* fix(ui): keep the Move-secret folder select's list inside its dialog
NcSelect defaults `appendToBody` to true, so vue-select TELEPORTS the options
list to <body>. A teleported list opened from inside an NcDialog is painted
behind that dialog and the control is simply dead: the options are in the DOM,
visible and enabled, but every click lands on the dialog. Choosing a
destination folder was impossible — Playwright retried the click ~100 times
over 60 seconds and each attempt hit <div class="dialog"> instead of the
option.
Raising the list's z-index does NOT fix it. Two attempts are recorded in the
history of this branch (10002 via .vs__dropdown-menu--floating, then via a
0-2-0 selector to beat vue-select's own rule); neither changed the outcome,
because the teleported list and the dialog end up in different stacking
contexts, so the list's z-index never competes with the dialog's at all. Those
CSS rules are removed again rather than left in place claiming a fix they do
not deliver; the stylelint string-quotes cleanups in the same file are kept.
Keeping the list inside the dialog removes the question entirely.
The underlying defect is nc-vue's — NcSelect teleports by default while
NcModal, which NcDialog builds on, creates a stacking context the teleported
node cannot rise above. Doriath renders 13 more NcSelects inside dialogs with
the same exposure; only this one is proven broken by a test, so only this one
is changed here.
* TEMPORARY: truncate the frontend bundle after the gate (positive control)
Proves the e2e suite genuinely exercises the Doriath frontend. Reverted in the
next commit — must NOT be merged.
* Revert "TEMPORARY: truncate the frontend bundle after the gate (positive control)"
This reverts commit bf2bcf6.
---------
Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Jobs without timeout-minutes fall back to GitHub's 360-minute default, so a hung runner burns six hours of Actions minutes before it is reaped. Bounds are derived from observed run durations and left deliberately loose: a timeout that fires under normal contention is worse than no timeout, because it turns a slow run into a phantom defect. Jobs that only call a reusable workflow (job-level `uses:`) are untouched -- they inherit their bound from the called workflow.
… hook Fleet convention (ConductionNL/.github CONVENTIONS.md § features.json): CI only verifies and blocks — generation happens on the developer's machine, before the checks run. - .githooks/pre-commit: regenerates docs/features.json whenever staged changes touch openspec/specs/ or the features overlay, and stages the result. Best-effort: it warns but never blocks the commit; the CI gate (features-check / features-extract -> Quality Report) enforces. - package.json "prepare" + composer.json "post-install-cmd" set core.hooksPath to .githooks, so any npm install or composer install activates the hook automatically. Existing clones activate once with `git config core.hooksPath .githooks`. Works from any client that runs real git (CLI, IDEs, Claude, GitKraken 9.5+); a bypassed or broken hook is caught by CI, which hard-fails the merge on a stale features.json.
ci: align workflows with fleet standard (ConductionNL/.github)
…#139) All four gates were failing on development against real findings, not tooling noise. Fixed the violations rather than baselining them. psalm (2 -> 0) and phpstan (34 -> 0) - Add tests/stubs/openregister-apphost.stub.php, an analysis-only stub for OpenRegister's AppHost Settings engine, wired via phpstan.neon scanFiles and psalm.xml <stubs>. Sections\SettingsSection and Settings\AdminSettings extend GenericSettingsSection / GenericAdminSettings, which live in the openregister sibling app and are absent from the CI analysis path. PHPStan refuses to ignore "extends unknown class" at all ("use excludePaths instead"), so a stub is the only non-destructive fix. Declaring GenericAdminSettings as IDelegatedSettings also resolves the 12 AuthorizedAdminSetting class-string errors. Mirrors the signatures in openregister/lib/AppHost/Settings/ and the openbuild precedent. Real defects fixed - CertificateAuthorityService::getStatus() docblock omitted the 'issued' key it actually returns, so DashboardService's CA-health card read an offset PHPStan proved could not exist. - Two dead guards on phpseclib saveX509(), declared ": string", where is_string($pem) === false can never be true. - EncryptionSuite::getCreatedAt() was annotated non-nullable while the backing property is ?DateTime and jsonSerialize() uses ?->. - TeamFolderService::registerFanOutShares() declared @return int but returns array{created, rows}. - ShareService::createBatchShares() declared a precise array shape for rows that arrive verbatim from an untrusted NoAdminRequired request body; the docblock invited a future reader to delete the runtime guard that is the actual validation. - Removed four injected-but-never-read LoggerInterface dependencies (AttachmentService, AuditService, CertificateLifecycleService, DelegationService) and their test wiring. - Removed ComplianceReportService::FORBIDDEN_KEY_PARTS: unused, and unusable as written since it would reject the legitimately allowlisted ciphertextAgeBands and linkSharesPasswordProtected keys. SECTION_ALLOWLIST is a closed set and strictly stronger; corrected assertAllowlisted()'s docblock, which claimed a check that was never implemented. - Removed SettingsService::ADMIN_CONFIG_KEYS: unused and a stale, incomplete duplicate of the keys updateAdminSettings() handles. phpcs (54 -> 0) - 14 auto-fixed with phpcbf; 25 missing named parameters; doc-comment capitalisation; two inline IFs expanded; missing @PARAM tags on EncryptionSuiteController and DashboardService; one over-long notification string shortened (msgid untranslated in every locale). stylelint (28 -> 0) - 111 rule-empty-line-before violations auto-fixed (blank-line insertions only, zero content changes). - Quoted the stylelint globs in package.json. Unquoted, src/**/*.vue is shell-expanded without globstar, so it matched ONE directory level and silently skipped src/components/settings, src/components/share and every other nested dir: CI saw 28 of the 111 real violations. Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Pin the exact version (no caret). The 3.0.0 major removes CnFlowCanvas, CnEditFlowsModal and CnFlowCanvasModal; this app imports none of them. Peer set is unchanged from 2.1.0-vue3.16, so no new peers are declared. Verified from the lockfile: @conduction/nextcloud-vue 3.0.0-vue3.4 exact, vue3-apexcharts 1.8.0 (below the proprietary 1.9.0 line). Built with USE_LOCAL_LIB=false; built js/ carries CnFlowEditModal and CnAppNav and none of the three removed symbols. Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…ro) (#144) * style(phpmd): import classes used with `new` instead of FQCN (MissingImport 18 -> 0) Adds the missing `use` statements for DateTime, ReflectionClass, RuntimeException, X509, ShareTarget, Secret, SettingsService, SettingsController and InitializeSettings, and switches the instantiation sites to the short names. No behaviour change. * refactor(phpmd): drop the error-control operator and count() in loop conditions ErrorControlOperator 6 -> 0, CountInLoopExpression 2 -> 0. - ServiceWorkerController: guard the shell read with is_file()/is_readable() instead of suppressing file_get_contents(). - New Support\SuppressesDiagnostics trait: one named, greppable, strictly scoped set_error_handler()/restore_error_handler() pair replacing the five scattered `@` marks on openssl_csr_get_public_key(), openssl_csr_new(), openssl_x509_parse() and stream_socket_client(). Those functions report malformed user input both as an E_WARNING and as a false return; the reason now lives in one place instead of being restated per site. - Background scan jobs: hoist count($page) into $pageCount before the do/while condition. No behaviour change: every call site still branches on the same return value. * refactor(phpmd): fix short/long variable names and unused formal parameters ShortVariable 12 -> 0, LongVariable 7 -> 0, UnusedFormalParameter 3 -> 0. Renames (behaviour-neutral; named-argument call sites and their tests updated in lockstep): - $s -> $secret, $p -> $params, $d -> $days, $fn -> $counter, $ca -> $caRows, $dn -> $dnParts, $ip -> $remoteIp - $encryptionSuiteService -> $suiteService - $rotationPolicyService -> $rotationService (also in SecretService, whose class-level LongVariable suppression already covered it, so the injected name stays uniform across all three consumers) - $attachmentGrantMapper -> $grantMapper - $encryptedAdditionalFields -> $encryptedExtras (the wire key 'encryptedAdditionalFields' is untouched — it is API contract) Two documented inline suppressions, not fixes: - HoneyAlert::$ip — the property name IS the column name QBMapper derives; renaming would retarget reads/writes at a non-existent ip_address column. Class-level because PHPMD does not read @SuppressWarnings off a field. - JwtAuthMiddleware::afterException($methodName) — overrides OCP\AppFramework\Middleware; narrowing the signature is a fatal error. - GroupShareController::denyNewMember($id, $newMemberId) — $id is the {id} URL segment the router binds; denial is a specified server-side no-op. * refactor(phpmd): remove boolean flag arguments (BooleanArgumentFlag 6 -> 0) Two real decompositions: - DelegationService::createDelegation($isAdminPath) split into createDelegation() (owner self-delegation) and createAdminHandover() (vault-admin power grab), sharing loadDelegableSecret() and persistDelegation(). The flag selected an AUTHORIZATION branch, which is exactly the case that should be two named entry points. Guard order, messages and persisted shape are unchanged; the five admin-path tests now call createAdminHandover(). - AttachmentService::unlinkBlobIfOrphaned($keepWhileRowExists) dropped. The single true call site was unconditionally a no-op: the callee returned on the flag before any unlink could run, so removing the call changes nothing observable. Replaced with a comment stating the invariant it encoded. Three documented inline suppressions, not fixes — in all three the bool is payload bound by name out of the HTTP request body, not a switch the method branches on: - SiemSinkController::create($tls, $enabled) — sink fields, passed straight through to the params array. - SecretRequestController::create($isReRequest) — a body discriminator; splitting it would split the route and change the HTTP contract. - SuiteMigrationCompletedEvent::__construct($hasErrors) — event payload, stored and read back via hasErrors(). * refactor(phpmd): remove every else branch (ElseExpression 31 -> 0) No suppressions — all 31 are decomposed. Four shapes were used: 1. Default-then-override for a value assigned in both arms ($status, $resultMessage, $band, $separator, entity setters). Safe because the overridden value is a pure literal or an in-memory setter. 2. A named predicate variable for QueryBuilder null-vs-equals filters (ExpiryPolicyMapper, FolderMapper, HoneyAlertMapper) — the predicate is built first, then applied once. 3. Extracted early-return helpers where both arms did real work: ApplicationSecretsController::fetchOwnedSecrets(), DashboardSettingsController::applyUpdate(), SecretController::createOwnedSecret(), SecretRequestController::dispatchCreate(), ApplicationService::registrationAuditEvent(), SiemService::deliverTo() (now the single syslog/webhook decision, shared by deliverOne() and testSink()). 4. match() where the branch picks between two side-effecting calls (insert vs update) or resolves a three-way discriminator, so the original single decision point is preserved. Guard against the obvious hazard of shape 1: SettingsService encodes the bool into a separate $encoded variable, because $value is both the condition and the target and a naive rewrite would have made every boolean preference store '0'. Verified no complexity was traded away: the CyclomaticComplexity and NPathComplexity finding sets are byte-identical to before this change. * refactor(phpmd): inject an AuditEventFactory instead of calling AuditEvent statically StaticAccess 63 -> 8. New lib/Event/Audit/AuditEventFactory.php exposes the four named constructors as instance methods; 22 classes now take it through constructor DI and call $this->auditEvents->forUser(...) etc. The factory is stateless, so each consumer defaults it to a fresh instance (`= new AuditEventFactory()`, PHP 8.1 new-in-initializers). Wiring it explicitly still works and is what the Nextcloud container does, but no existing construction site — production or test — had to change. AuditEvent keeps its static named constructors: they are the value object's own API and tests exercise them directly; the factory delegates rather than duplicating the ACTOR_* mapping, and carries the one documented StaticAccess suppression for those four delegations. SecretService's class-level @SuppressWarnings(PHPMD.StaticAccess) is REMOVED — its stated reason ("AuditEvent::forUser / forApplication ARE the audit API") no longer holds now that there is an injectable seam. Measured trade-off, reported rather than hidden: adding the dependency raises CouplingBetweenObjects by 1 in each consumer, which pushes LinkShareService from 12 to 13 and thus adds ONE new CouplingBetweenObjects finding (16 -> 17). Every one of its twelve imports is used; reducing it belongs to the coupling pass, not here. Net phpmd 143 -> 85. phpcs 0, psalm 0, phpstan 0, phpunit 707 pass. * refactor(phpmd): clear the last 8 StaticAccess findings (63 -> 0) Real fixes (5): - AuditEventTypes::whitelist() becomes `public const WHITELIST`. The table was already a compile-time literal, so a static accessor bought nothing; constant access is a value read, not a static call. (2 findings) - phpseclib3\\Crypt\\PublicKeyLoader is now reached through the injectable Support\\PublicKeyLoaderAdapter, wired into CertificateAuthorityService. (3 findings) - Jose JWKFactory is now reached through Support\\JwkFactoryAdapter, wired into JwtAuthService. (1 finding) Both adapters are thin and stateless, default-constructed in the consumer like AuditEventFactory, and each carries ONE documented suppression naming the library and why no instance API exists — these libraries expose key construction exclusively as static factories with nothing to instantiate. Documented inline suppressions (2), where no injectable alternative exists: - ApplicationSecretsController::parseIso8601 — DateTime::createFromFormat is a named constructor on a PHP built-in; ITimeFactory only produces "now". - Application::register — Bootstrap::register is a cross-app static entry point in a SIBLING app that may be absent (hence the class_exists guard and catch(\\Throwable)); this method IS the composition root, and a typed dependency on a possibly-absent foreign class would 500 every route. Turning the whitelist into a const gave PHPStan literal types and it then proved SiemService::buildPayload's forbidden-key check unreachable. The check is NOT removed: it is what stops secret material reaching a SIEM sink if a whitelist row is ever widened. The two predicates are now evaluated up front instead of short-circuited, which keeps the guard live and breaks the narrowing that made it look dead. Comment records why. phpmd 77 (from 225). phpcs 0, psalm 0, phpstan 0, phpunit 707 pass.
…145) * refactor(phpmd): decompose the notifier and honey tripwire dispatchers - DoriathNotifier::prepare() split into four subject-family renderers (sharing, secret lifecycle, admin, vault access) plus a shared withAdminSectionLink() helper; CC 20 -> 7, 193 lines -> 33. - HoneyTripwireListener::handle() split into resolveRemoteIp(), resolveUserAgent() and trip(); CC 12 -> 3, NPath 290 -> 3. - AuditController::me() renamed to mine() (ShortMethodName). The route name becomes audit#mine; the public URL /api/v1/audit/me is unchanged. phpmd 77 -> 72. * refactor(phpmd): decompose ShareService bulk registration and sync - registerDirectShares() delegates each row to registerDirectShare() (validation) and createDirectShare() (owner guard, idempotency, copy + ShareTarget insert); CC 13 -> 4, NPath 514 -> 4, 118 lines -> 20. - syncUpdate() delegates to resolveSyncWriter(), assertSyncSourceUnchanged(), collectSyncTargets(), applySyncUpdates() and applyRecipientBlob(); CC 22 -> 3, NPath 46800 -> 3, 124 lines -> 32. Behaviour is unchanged; the transaction boundary, the membership guard and the skip-not-fail statuses all move intact. phpmd 72 -> 66. * refactor(phpmd): decompose TeamFolderService membership, fan-out and offboarding - addMember() delegates to assertMemberAddable() and findOrCreateMembership(); CC 10 -> 1, NPath 216 -> 1. The user/group validation is regrouped by member type, which removes the repeated $memberType comparisons without changing which input is rejected. - registerFanOutShares() delegates each row to createFanOutShare(); CC 12 -> 6, NPath 200 -> 6. The 'created' count now derives from the created rows it already collected. - offboard() delegates to revokeOffboardedShares() and transferTeamSecrets(); CC 10 -> 4. phpmd 66 -> 61. * refactor(phpmd): group admin settings by domain, split the JWT assertion exchange - SettingsService::updateAdminSettings() now delegates to updateAuthenticationSettings(), updateInstanceSettings(), updateExpirySettings(), updateLeaseSettings() and updateRetentionSettings() alongside the existing updatePolicySettings(). CC 31 -> 1, NPath 20155392 -> 1, 159 lines -> 13. The write order of every key is preserved exactly, so partial-write behaviour on a mid-payload rejection is unchanged. - JwtAuthService::exchangeAssertion() now delegates to deserializeAssertion(), readAssertionClaims(), assertClaimsAcceptable(), loadActiveIssuer(), loadIssuerKey() and issueAccessToken(). CC 18 -> 4, NPath 73728 -> 4, 124 lines -> 26. The jti replay check stays in the caller, between claim validation and signature verification, exactly where it was. phpmd 61 -> 55. * refactor(phpmd): decompose the remaining oversized create/register paths Controllers: - SecretController::create() moves its param validation into assertCreatable(), which throws InvalidArgumentException — the same 400 the caller already produced for those two messages. CC 12 -> 5. - SecretRequestController::create() moves expiry parsing into parseExpiry(), likewise throwing into the existing 400 mapping. CC 10 -> 7. - SiemSinkController::update() moves the six optional-field guards into collectSinkChanges(). NPath 256 -> 4. Services: - EphemeralSendService::create() delegates to resolvePayloadType(), resolveMaxViews(), resolveExpiry() and assertPasswordFieldsConsistent(). CC 13 -> 2, NPath 576 -> 2. - CertificateAuthorityService::resignPreservingPublicKey() delegates to rsaModulusOf() (used for both the old and the new certificate) and signPreservingSubject(). CC 12 -> 6, NPath 720 -> 6. - ApplicationService::register() delegates to assertRegistrable(), buildApplicationRow() and runPostRegistrationHooks(). CC 13 -> 1, NPath 480 -> 1. - HoneyCredentialService::raiseAlert() delegates to recordHoneyAccess() and shouldCollapseAlert(). CC 12 -> 3. phpmd 55 -> 45. * refactor(phpmd): clear the last CyclomaticComplexity and NPathComplexity findings - RotationPolicyService::resolveEffectiveExpiry() delegates candidate collection to policyExpiryCandidates() and configuredExpiryCandidates(); CC 10 -> 4. - RotationPolicyService::upsertPolicy() delegates to assertPolicyInput(), encodeReminderDays() and findScopedPolicy(); CC 10 -> 2. - SecretRequestService::fill() moves the requested-field checks into assertRequestedFieldsFilled(); CC 10 -> 5. An unparseable requestedFields JSON still means 'nothing to check', as before. - SecretService::fuzzyMatch() moves the per-page Levenshtein scan into fuzzyPageHits() and unions the result in; CC 11 -> 8, NPath 244 -> 44. The union keeps left-hand keys and the helper already skips ids that are matched, so the merge is exactly the previous assignment. CyclomaticComplexity 19 -> 0 and NPathComplexity 12 -> 0. phpmd 45 -> 40. * refactor(phpmd): shorten the composition root and the CA-certs migration - Application::register() moves the service overrides into registerDomainOverrides() and every listener binding into registerDomainEventListeners(); 194 lines -> 74. The AppHost class_exists() guard and the Bootstrap::register() call deliberately stay in register(): that guard is also the assertion psalm relies on to accept the call, and psalm does not carry the narrowing across a method boundary (extracting it produced a real UndefinedClass error). - Version000002Date20260331000001::changeSchema() moves the column definitions into createCaCertsTable() and the 25-line rationale for the nullable is_active BOOLEAN into the class docblock; 107 lines -> 8. ExcessiveMethodLength 7 -> 0. phpmd 40 -> 38.
…t gate (#148) `quality / Frontend Tests (unit)` went red on development (run 30884131373) with a single failure: tests/store/import.spec.js > useImportStore > round-trips an export .doriath-backup into committed, decryptable secrets Error: Test timed out in 5000ms. (1 failed | 424 passed) The test was not wrong and nothing it asserts had regressed - it passed locally on the same commit. The cause is cost, not correctness. `generateKeyPair()` is a real WebCrypto RSA-4096 keygen, i.e. a random prime search with unbounded, high-variance runtime: 12 local samples spread 154-850ms (median 464ms). import.spec.js called it NINE times, once per `unlockSession()`, and paid that variance nine times over against vitest's 5000ms per-test default while 69 spec files competed for a two-core runner. That is a coin flip, not a gate. Fix: cache the key pair per spec file and reuse it. The keys stay real and every encrypt/decrypt assertion still runs RSA-OAEP against a genuine key - only the redundant regeneration goes. Vitest isolates module state per file, so the first test pays for one keygen and the rest reuse it. The specs assert the crypto round-trip, never key uniqueness, so a shared pair is equivalent. `beforeEach` still recreates pinia, so no store state leaks between cases. Applied to the three RSA-heaviest specs via a shared `sharedKeyPair()` / `secondaryKeyPair()` helper in the existing rsa-fixtures module. `secondaryKeyPair()` keeps the emergency-envelope wrong-key negative control honest - it must be a genuinely different pair - and rsa.spec.js's 'generateKeyPair emits a valid SPKI PEM' case deliberately keeps its own fresh call, because there key generation IS the subject. Measured, whole suite, same machine: tests/store/import.spec.js 4218ms -> 887ms tests/vitest/rsa.spec.js 7817ms -> 2095ms tests/vitest/emergencyEnvelope 4360ms -> 2794ms total 'tests' time 50.83s -> 33.93s 69 files / 425 tests passed, exit 0 No timeout was raised, no test skipped, no assertion weakened. Positive controls (each reverted after): - pointing the envelope's 'attacker' at sharedKeyPair() instead of secondaryKeyPair() -> 'cannot be opened by a non-grantee' FAILS (1 failed | 3 passed), proving the two pairs really differ and the negative control still discriminates. - changing the import round-trip's expected plaintext to a wrong value -> FAILS (1 failed | 9 passed), proving the assertion is live. No PHP file is touched by this commit.
…all/psalm/phpstan (#149) The guard made the scripts always exit 0. Measured: 707 tests now run and the suite fails when an assertion is broken. Cost stated: 'PHP Quality (phpmd)' and the dependent 'Quality Report' fail on this PR and fail identically on development (run 30893158203) - pre-existing, not caused by this change. All four PHPUnit legs are green.
…150) DashboardService carried two unrelated responsibilities behind one constructor: - per-user preference storage (DashboardSettingMapper + logger), consumed by DashboardSettingsController; - a cross-domain read-only summary aggregation with seven counter dependencies (SecretMapper, FolderMapper, ShareTargetMapper, ApplicationMapper, RotationFlagMapper, CertificateAuthorityService, HoneyAlertMapper), consumed by DashboardController. The two halves shared no state and no caller, so the split is a pure move: fetchSummary(), caHealthCard() and safeCount() move verbatim to the new DashboardSummaryService, which grows its own copy of the two-line validateUserId() guard. DashboardController now injects the new service; both services are autowired, so no DI registration changes. Clears CouplingBetweenObjects on DashboardService (16 -> under 13). No @SuppressWarnings, no baseline, no threshold change. The four fetchSummary test cases move verbatim to a new DashboardSummaryServiceTest; the only edit is the constructor call losing the DashboardSettingMapper the aggregator never used. Suite total is unchanged at 707 tests / 2281 assertions. Verified on PHP 8.4 (container; host PHP 8.2 cannot run this tree): phpmd 38 -> 37 findings, exit 2 (Dashboard finding gone) phpunit 707 tests / 2281 assertions, exit 0 on PHP 8.3 and 8.4 vitest 425 tests / 69 files, exit 0 phpcs exit 0 (clean before and after) psalm exit 0 phpstan exit 0
chore(deps): @conduction/nextcloud-vue 3.0.0-vue3.6
…b (37 -> 35) (#153) lib/Db is exclusively OCP\AppFramework\Db\Entity subclasses and their QBMappers. In an Entity one protected property IS one database column -- Nextcloud derives the column list, the magic getters/setters and the dirty-field tracking from the declared properties -- so TooManyFields is measuring the width of a table, not a design defect. Db\Secret 19 fields -> doriath_secrets Db\SiemSink 18 fields -> doriath_siem_sinks Cross-checked against the migrations that own those tables: Version000008Date20260604000002 creates doriath_secrets with 15 addColumn() calls and Versions 16, 17 and 22 each getTable() it and add more. The only change that takes Secret under the threshold of 15 is splitting the app's core secrets table in a destructive migration, i.e. the rule is unreachable by refactoring PHP. Mechanism: phpmd-db.xml refs phpmd.xml and excludes exactly the two rules; composer phpmd runs the full ruleset over lib with lib/Db excluded and phpmd-db.xml over lib/Db, taking the worst exit code so neither leg can be short-circuited away. Neither rule offers a per-class exception surface (TooManyFields has only maxfields, ExcessiveClassLength only minimum/ignore-whitespace), so a threshold bump would have stopped reporting a 19-field service too. A top-level <exclude-pattern> was rejected for the opposite reason: it filters in PDepend, dropping lib/Db from EVERY rule, which would have silently hidden the real ShareTargetMapper TooManyPublicMethods finding. No @SuppressWarnings, no baseline file. Probe (two byte-identical 20-field, 1137-line, 11-public-method classes, one in lib/Db and one in lib/Service): rule lib/Service lib/Db TooManyFields REPORTED not reported ExcessiveClassLength REPORTED not reported TooManyPublicMethods REPORTED REPORTED ExcessiveMethodLength REPORTED REPORTED ShortVariable x10 REPORTED REPORTED ShortMethodName x10 REPORTED REPORTED Also removes a nested <exclude-pattern>*Migration*</exclude-pattern> from the UnusedFormalParameter rule. It was dead: PHPMD reads exclude-patterns in RuleSetFactory::getIgnorePattern(), which walks only elements directly under <ruleset>, so a nested one parses and does nothing. Verified that removing it changes neither the finding list nor the exit code. phpmd 37 -> 35, exit 2 -> 2. The gate is still red on 35 complexity and coupling findings, 22 of them on security-critical classes; see the follow-up issue. Refs #152. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…pping (#155) A skipped job and a passing job are indistinguishable in the Quality Report. Every gate turned on here reported 'skipped' in every run. Each newly-enabled leg was measured against this tree BEFORE being enabled; the results are in the PR description. Legs that were measured failing are enabled anyway - the defects are pre-existing, and the only thing that changed is that CI can now see them. Journeydoc Capture and enable-axe are deliberately NOT enabled.
… 32 migration suppressions (#157) lib/Migration was carrying 32 @SuppressWarnings(PHPMD.UnusedFormalParameter) docblock tags. They existed because the exclusion that was supposed to replace them never worked: the rule used to carry a NESTED <exclude-pattern>*Migration*</exclude-pattern> and a nested exclude-pattern is INERT - PHPMD only reads them as direct children of <ruleset>. The element was later removed with a note claiming that a top-level pattern was the only alternative and that "nothing in lib/Migration is currently reported" anyway. Both halves of that note were wrong, and it has been replaced with an accurate one. Wrong half 1: a ruleset of its own gives the pattern a top-level home whose blast radius is that single rule. phpmd-unusedparams.xml now holds UnusedFormalParameter alone with a top-level */Migration/* exclude-pattern, and runs as a third leg alongside the existing phpmd.xml and phpmd-db.xml legs. The lib/Db leg is untouched. The new leg runs over all of lib, including lib/Db, because phpmd-db.xml imports phpmd.xml and would otherwise lose the rule entirely. Wrong half 2: "nothing in lib/Migration is currently reported" was an artefact of the 32 suppressions. Strip them and lib/Migration reports 64 UnusedFormalParameter findings - every one on a changeSchema / preSchemaChange / postSchemaChange signature mandated by OCP\Migration\IMigrationStep, which a step cannot alter even when it uses none of the three parameters. The scoped ruleset replaces all 32 tags, which are deleted here. The pattern is */Migration/* and not *Migration*: the latter would also have swallowed lib/Service/MigrationService.php, lib/Controller/MigrationController.php, lib/Db/SuiteMigration.php and the SuiteMigration events and listeners. MEASURED - PHPMD 2.15.0 / PHP 8.4.22, three legs, worst exit code: suppression-free true count 205 -> 141 retired 64 UnusedFormalParameter, all in lib/Migration newly appearing 0 lib/Migration still reports 2 ExcessiveMethodLength + 1 StaticAccess shipped reported count 35 -> 35, finding list byte-identical Positive-controlled with probe classes in lib/Migration, lib/Db and lib/Probe: the Migration probe's UnusedFormalParameter is dropped while its ElseExpression is still reported by leg 1, and the lib/Db and lib/Probe probes report from leg 3 only - so the exclusion is real, it is scoped to the one rule, and no finding is double-counted across legs. Also fixes the tag-value indentation PHPCS error that deleting the tag above @SuppressWarnings(PHPMD.StaticAccess) exposed in Version000004. No phpmd.baseline.xml exists in this repo; none was added or removed. No @SuppressWarnings was added. The 5 ExcessiveMethodLength and 1 StaticAccess suppressions in lib/Migration, and the 18 UnusedFormalParameter suppressions elsewhere in lib, are untouched. phpunit -c phpunit-unit.xml: 707 tests / 2281 assertions before and after. Refs #156
…ue3.6 was unpublished) (#158)
…ad deleted, 79 kept with a written reason (#159) * refactor(phpmd): adjudicate all 106 @SuppressWarnings — 27 deleted as dead, 79 kept with a written reason doriath's SHIPPED phpmd count (35) and its TRUE count (141, measured with every @SuppressWarnings stripped on a throwaway copy) differ by 106. This change audits every one of those 106 tags. It does not move either number, and that is the honest result — but the repo now states its debt truthfully instead of carrying 27 tags that suppressed nothing and 28 that gave no reason. | measure | before | after | |----------------------------------|--------|-------| | SHIPPED findings | 35 | 35 | | TRUE findings | 141 | 141 | | @SuppressWarnings tags in lib/ | 106 | 79 | | dead (misleading) tags | 27 | 0 | | kept tags with NO written reason | 28 | 0 | | unit suite | 707/2281/1 skipped | 707/2281/1 skipped | There is no phpmd.baseline.xml in this repo and there must not be: PHPMD AUTO-DISCOVERS that filename, so a baseline would stay active even after the `baseline` CLI flag is removed. phpmd.xml, phpmd-db.xml, phpmd-unusedparams.xml, phpcs.xml, phpunit*.xml, composer.json and tests/ are all byte-unchanged. Method ------ Adjudicating a tag requires knowing which finding it hides, and `mktrue.sh`-style tag DELETION shifts every line below it, so triples stop being comparable across the two trees. The audit therefore used a LINE-PRESERVING true tree — each tag line replaced by ` *` rather than removed. `TRUE − SHIPPED` then yields exactly the 106 findings the tags actually suppress, each mappable to one tag. The 27 dead tags ---------------- 14 LongVariable (12 lib/Db entities + 2 SecretController), 3 migration ExcessiveMethodLength, 3 CertificateAuthorityService StaticAccess, plus RotationController/SiemService TooManyPublicMethods, ShareRequestService CouplingBetweenObjects, SecretController CyclomaticComplexity, and 3 within-file misplacements. VERIFIED rather than asserted: after deleting all 27 the shipped set was unchanged — 35 findings, same triples. A live deletion would have shown up as a new finding. Two mechanism findings worth recording: * CertificateAuthorityService carried 3 StaticAccess tags citing `Uuid::uuid4()`, which IS called at 3 sites — yet PHPMD reports ZERO StaticAccess in that file. The call is nested inside an argument list (`setId(Uuid::uuid4()->toString())`). The same run reports StaticAccess at 14 other places, so the absence is real, not a dead scan. * `KeyGeneratorService::generateFromCharset`'s `bool $includeSpecial` has NO default, so BooleanArgumentFlag cannot fire on it — the rule keys on the DEFAULT VALUE, not the call site. Confirmed before deleting. Why the shipped 35 did not move ------------------------------- Real cleanups were made (12 genuinely dead imports removed across 12 files, e.g. SecretTypeService importing a Repair\ class), but none of them moved a finding, and saying otherwise would be false. PDepend's CouplingAnalyzer counts AST references — @throws types, property/param/return types — not `use` lines. That was read first and then confirmed empirically: SecretVersionService stayed at 13, LeaseService at 14. Only line numbers moved. All 35 remaining are large-class / coupling metrics whose only honest fix is architectural decomposition: 16 CouplingBetweenObjects (13-37 vs 13; Application at 37 is the DI composition root), 10 ExcessiveClassComplexity (52-95 vs 50), 4 TooManyPublicMethods, 3 ExcessiveParameterList (12-15 ctor DI params vs 10), 1 TooManyMethods, 1 ExcessiveClassLength (1466 vs 1000). Four of the coupling findings sit at exactly 13 and could be dropped under the line by deleting a single type reference (e.g. `new DateTime()` -> `date()`). That is rule-dodging, not debt reduction, so it was not done. @SPEC anchors ------------- 28 `@spec` tags in the touched files carried a `#requirement-…` fragment that exists in NO version of its target — neither the archived change spec nor the canonical `openspec/specs/` one. `#requirement-bounded-delivery` against a spec whose headings are "Admin-configured syslog and webhook sinks", "Forwarded payload carries no secret material", "Reliable background delivery", "Backpressure and observability"; `#requirement-mark-rotated` against "Proven mark-rotated flow"; and so on. They are shorthand invented by an earlier annotation pass. gate-46 (spec-anchor-existence) is diff-scoped, so these only surface once the file is edited — which this change does. The fragments are DROPPED rather than re-guessed: which Requirement each method implements is exactly what no version of the spec substantiates, and wrong traceability is worse than none. The path itself resolves and is kept, so each tag still says truthfully "this code implements something in this spec". Pre-existing issue fixed ------------------------ The docblock edits exposed the repo's column-alignment sniff: two @SuppressWarnings in one docblock must have column-aligned tag values. Fixed in KeyGeneratorController.php and KeyGeneratorService.php (8 spaces, matching the existing convention in SiemService). Not done, with reasons ---------------------- * Splitting TeamFolderService (1466 lines / 37 methods / 15 ctor deps) would alone clear 3 findings and is the highest-value follow-up. Deliberately not attempted here: it changes public service boundaries, the extracted collaborator would inherit most of the same mappers (so the ctor list may not even drop below 10), and a speculative split of that size needs design review, not a debt-burndown pass. * Removing `bool $x = false` defaults to silence 7 BooleanArgumentFlags moves code out of the rule's detection window without fixing the SRP smell. Most are Nextcloud router request-param bindings where an optional field MUST carry a default; that constraint is now written into each reason instead. Each claim was verified (e.g. SettingsService::$force appears exactly once, passed straight through to OpenRegister's ADR-022 signature). * Splitting Version000001's changeSchema (109 vs 100 lines) — it is the initial-schema migration every fresh install runs; 9 lines is not worth that. * Repointing the remaining `openspec/changes/…` @SPEC paths to canonical `openspec/specs/…` is a repo-wide policy cleanup of its own; the paths all resolve today via the archive index. Verified inside nextcloud:latest (PHP 8.4.22) with `composer install` run IN this worktree — host PHP 8.2 kills vendor/bin/phpmd in platform_check.php with exit 255, which reads exactly like a clean run. phpcs 0 errors, psalm exit 0, phpstan exit 0, PHPUnit 707/2281/1 skipped exit 0, phpmd shipped set identical to development's. * test(phpmd): lock in the two suppression-hygiene invariants this audit established gate-47 (security-change-has-tests) went red on this PR, and correctly by its own contract: the diff touches 13 security-classified files under lib/ (they match on `IUserSession`, `#[NoAdminRequired]`, `getSecureRandom`, …) and changed no test file. The gate offers an `exclude` opt-out; that is a waiver, so it was not used. Instead, the invariants this audit created are now asserted. Both are real guarantees that did not exist before and can regress silently: 1. testEverySuppressionCarriesAWrittenReason — every @SuppressWarnings under lib/ has a reason of at least 12 characters on the tag's own line. Before this PR, 28 of 106 had none. Without the assertion the next unexplained tag reads exactly like an adjudicated one. 2. testNoAutoDiscoveredPhpmdBaselineExists — no phpmd.baseline.xml anywhere. PHPMD AUTO-DISCOVERS that filename, so a baseline stays active even after the `baseline` CLI flag is removed from composer.json; its mere presence would silently subtract findings from every leg of `composer phpmd` while the run still looked clean. 3. testScanReachesTheSourceTree — a positive control on the scan itself. A wrong path would make both assertions above pass vacuously; an empty scan and a clean one are otherwise indistinguishable. Source-scanning tests are an established shape in this repo — see KeyGeneratorServiceTest::testSourceUsesNoWeakRandomness(). NEGATIVE CONTROL, because a passing assertion is only evidence about the assertion until it has been shown to fail. With a bare `@SuppressWarnings(PHPMD.UnusedFormalParameter)` dropped into a throwaway lib/ file and an empty phpmd.baseline.xml touched at the repo root: Tests: 710, Assertions: 2285, Failures: 2, Skipped: 1 1) …::testEverySuppressionCarriesAWrittenReason @SuppressWarnings without a written reason (>= 12 chars …) 2) …::testNoAutoDiscoveredPhpmdBaselineExists PHPMD auto-discovers phpmd.baseline.xml; none may exist in this repo Both throwaways were removed; the tree is clean. Suite: 707/2281/1 skipped -> 710/2285/1 skipped, exit 0. phpcs 0 errors, psalm exit 0, phpstan exit 0, phpmd shipped count unchanged at 35. * fix(phpmd): narrow the UnusedFormalParameter exclusion from */Migration/* to */lib/Migration/* `*/Migration/*` is a path-SEGMENT pattern, not a directory anchor. PDepend compiles an exclude-pattern into an UNANCHORED regex — Input\ExcludePathFilter preg_quote()s the pattern and then turns `\*` into `.*` — so it matches ANY path containing a `/Migration/` segment: `lib/Service/Migration/`, `lib/Command/Migration/`, any future `lib/*/Migration/`. Those are ordinary classes with no interface-mandated signature, and a genuine unused parameter in one would never be reported while the run still looked clean. doriath is the repo where this was closest to biting: it already carries MigrationController, MigrationService, SuiteMigration, SuiteMigrationMapper and two SuiteMigration listeners. None of them sits under a `/Migration/` DIRECTORY today — `Migration` is part of the class NAME, and the compiled regex requires the slashes — so nothing was actually being swallowed. One `lib/Service/ Migration/` refactor away, it would have been. PROBE MATRIX, PHPMD 2.15.0 / PHP 8.4.22, three probe classes on a throwaway copy: probe */Migration/* */lib/Migration/* lib/Migration/… UFP not reported not reported (intended) lib/Migration/… Else (leg 1) reported reported (leg 1 unaffected) lib/Service/… UFP reported reported lib/Service/Migration/… UFP NOT REPORTED reported (the leak) The IMigrationStep exclusion still does exactly its job; leg 1 still analyses lib/Migration for every other rule; and the leak is closed. Real numbers: UNCHANGED. Shipped 35 -> 35, TRUE 141 -> 141. Nothing was being hidden, which is what the grep for `/Migration/` directories outside lib/Migration/ already predicted — but the trap is gone regardless. One process note worth recording: the first draft of this comment contained a literal `<-` inside the <description>, which made the ruleset UNPARSEABLE. PHPMD then exited 1 and reported NOTHING — and "no findings printed" is exactly what a clean run looks like to a grep. It was caught only by reading the exit code directly. The XML is now validated as part of the check.
…ot-applicable (#160) hydra-gates-require-full-coverage (default ON) fails the run when a gate whose subject matter exists did not report, and its contract is that a legitimately not-applicable gate must DECLARE itself: 'A gate that emits nothing at all still counts against coverage.' v1.0.1 contains ZERO _skip calls — it has no na/structural/wiring vocabulary whatsoever, so any gate whose prerequisite is absent emits nothing and is counted as DID NOT RUN. v1.3.0 contains 36 such declarations. Measured here, run 31019022177 on development: COVERAGE: 58 of 61 declared gates reported a result. GATES THAT DID NOT RUN: 4 24 33 None of the three is starved by a skipped producer job: gate-4 is diff-scoped out, gate-24's input is scripts/check-integration-parity.sh in this repo, and gate-33's input needs enable-axe. In that run Playwright PASSED and Newman RAN, and 24/33 still did not report.
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.
…his spec Every scenario in secrets-write-ui is now accounted for: 15/15, ten by a Playwright test and five by an exclude that names the tests actually driving it. None of the excludes claims coverage that does not exist — that was the whole point of not waiving these earlier. The three closed here had NO coverage of any kind, and all three are older than the work that surfaced them: - **Name and value are required** and **Create a secret inside the current folder** — carried across PR #270 and #282 as a known gap. SecretCreateDialog.requiredFields walks every partial state (including whitespace-only), asserts no request is made, and covers the folder default plus the null-folder case at the vault root. - **Edit metadata only** — SecretEditDialog.metadataOnly asserts only the name is sent when only the name changed, nothing at all is sent when nothing changed, and the counterpart: the value IS re-encrypted when it really changes. That matters beyond wasted work — re-encrypting on every rename would rewrite the whole additional-fields blob, which is what loses members another session added. A harness trap is documented in both files, because it silently defeats tests of this exact kind: `sessionStore.isLocked = false` is a NO-OP. It is a getter over `cryptoKey`, so the assignment does nothing (Vue logs "target is readonly"), the dialog stays locked, canSubmit stays false, and submit() returns having done nothing — a test asserting on the resulting call would simply never see one. The existing SecretCreateDialog.generator spec does the same thing. Two disclosures. First, commit 4fcc170 carries content its message does not describe: the registry-dispatch spec and two scenario annotations were swept into it by a `git add -A` before I had written their commit. The work is right, the message is incomplete, and history stays as it is rather than being rewritten. Second, eslint --fix and prettier disagree on these files and will undo each other. Prettier has to run last; after that both are clean. Assisted-by: ClaudeCode:claude-opus-5
…bs/php_codesniffer-4.0.4 build(deps-dev): bump squizlabs/php_codesniffer from 3.13.6 to 4.0.4
Bumps [twig/twig](https://github.com/twigphp/Twig) from 3.27.0 to 3.28.0. - [Release notes](https://github.com/twigphp/Twig/releases) - [Changelog](https://github.com/twigphp/Twig/blob/3.x/CHANGELOG) - [Commits](twigphp/Twig@v3.27.0...v3.28.0) --- updated-dependencies: - dependency-name: twig/twig dependency-version: 3.28.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com>
…60819081801 chore(release): 0.1.6-unstable.20260819081801
…uuid-4.9.3 build(deps): bump ramsey/uuid from 4.9.2 to 4.9.3
…ud/ocp-34.0.3 build(deps-dev): bump nextcloud/ocp from 34.0.2 to 34.0.3
…ib/phpseclib-3.0.56 build(deps): bump phpseclib/phpseclib from 3.0.55 to 3.0.56
…ig-3.28.0 build(deps-dev): bump twig/twig from 3.27.0 to 3.28.0
…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
…expiry-lifecycle feat(secret-requests): make an expiry something that is acted on, not only checked
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).
…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)
Brings beta up to date with development for tonight's fleet-wide beta checkup, so tomorrow's review runs against current code. Conflict resolved: package.json — took development's dependency set. Beta's `linkifyjs` is dropped: development neither declares nor imports it (verified), so beta would be shipping a dependency its own source never uses. Beta-only files that did not conflict are preserved.
Contributor
Quality Report — ConductionNL/doriath @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 117/117 | |||
| npm | ✅ | ✅ 533/533 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-20 20:42 UTC
Download the full PDF report from the workflow artifacts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings beta up to date with development for tonight's fleet-wide beta checkup (team decision, 2026-08-20), so tomorrow's review runs against current code on the online beta environment.
Why this PR and not the auto sync PR: the automatic
development -> betaPR was CONFLICTING and could not merge. This branch is that same merge, with the conflict resolved.Resolution policy: for files development actively maintains, development's version wins — that is what a release-channel sync means. Beta-only files that did not conflict are preserved untouched.
The per-conflict reasoning is in the merge commit message.
Also carried to beta: the converged
release.yml(beta's pre-convergence workflow has noworkflow_dispatchtrigger), and the app signing path that started working today once theNEXTCLOUD_SIGNING_KEY/CERTsecrets landed — the first successful releases since 2026-08-17.