Skip to content

Release: merge development into beta - #691

Open
github-actions[bot] wants to merge 128 commits into
betafrom
development
Open

github-actions[bot] wants to merge 128 commits into
betafrom
development

Conversation

@github-actions

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.

rjzondervan and others added 30 commits September 10, 2026 13:08
OpenSpec change harden-vault-key-material-guards: proposal, design, tasks,
and spec deltas for a verified master-password proof (VaultKeyProof) gating
the irreversible key-material operations, plus a migration abort route.
Reproduced end-to-end against development; findings 1 and 2 documented in
the proposal. Spec only — implementation lands in a separate commit.

Refs #673
Assisted-by: ClaudeCode:claude-opus-5
…674)

OpenSpec change migrate-emergency-access-on-rotation: proposal, design,
tasks, and spec deltas. A compromise-recovery rotation re-envelopes each
reachable emergency contact under the new key (buildRecoveryEnvelope with
the new private key + the grantee's current certificate) and invalidates
only the residual, correcting the spec's claim that the owner cannot
re-wrap it alone. Spec only — implementation lands separately.

Refs #674
Assisted-by: ClaudeCode:claude-opus-5
The "any completed rotation silently costs emergency access" open question
is resolved by #674 (migrate-emergency-access-on-rotation), which
re-envelopes reachable contacts under the new key. The lost-password
route's destructive mechanics (the revocation warning and the
refuse-while-a-usable-contact-exists gate) are likewise folded into #674;
this note records where each piece now lives.

Refs #673
Assisted-by: ClaudeCode:claude-opus-5
…674

Extends this change to carry #395's lost-password-route safeguard: revoking
a user suite still clears its emergency envelopes, but must now warn plainly
(secrets gone, emergency access deleted, accessor must retrieve first while
the suite is active), refuse while a usable emergency contact exists unless
an explicit override is given, and surface the count of usable contacts
(never identities). Belongs here because the guard in #673 makes revocation
the only forgotten-password route, and the clearing is emergency-access
lifecycle on a suite key-state transition — the surface this change owns.

Adds spec scenarios (refuse-without-override, proceed-with-override), a
design decision D5, a tasks section 4b, and proposal/impact notes.

Refs #674
Assisted-by: ClaudeCode:claude-opus-5
…673)

Correcting the abort spec discovered during implementation: revoking the
unused successor suite would run EncryptionSuiteRevokedListener, which for a
user suite sweeps the owner's incoming ShareTargets and promotes their
delegations — destroying real state over a migration the abort exists to
undo. The successor is brand-new and empty, so it is deleted outright.

Refs #673
Assisted-by: ClaudeCode:claude-opus-5
The abort route the compromiseRecovery refusal already promises but that did
not exist — the remedy named in the error message. It is the non-destructive
terminal: completion carries the vault forward to the new suite and marks the
old one compromised; abort carries it back to the old suite, which stays
active and readable.

Abort is permitted only while no record has been committed to the new suite
(MigrationWorkService::countCommitted). Once a record has moved, both
outcomes lose data, so the migration stays in_progress and the caller is
pointed at resuming — a 409 carrying the committed count. This restriction is
also exactly what makes abort safe against the session-only lockout: producing
a valid re-encrypted record needs the master password, so a hostile session
that never held it can never have committed one and can always be aborted away.

On success: status -> aborted, the successor suite is deleted (not revoked,
which would cascade the user-suite lost-identity teardown), failure accounting
is cleared, the write lock is released, and SuiteMigrationAbortedEvent fires —
NOT SuiteMigrationCompletedEvent, so the terminal cascade (compromise-flagging,
link-share revocation, emergency-access invalidation) never runs. Its one
listener unlocks the SecretRequests locked at start, keeping them on the old
suite.

Frontend: an "Abort and keep my old key" control on the resume banner plus the
abortMigration store action. Backend + store fully unit-tested (abort
restores/deletes; refused-after-commit with count; idempotent; aborted-not-
completed event); phpmd clean; prettier clean.

Refs #673
Assisted-by: ClaudeCode:claude-opus-5
The core of the fix for the session-only lockout (#395). A destructive
operation on vault key material now requires a VaultKeyProof: a signature,
made with the caller's suite private key, over a server-issued challenge
bound to the operation's own parameters. The private key is obtainable only
by decrypting its envelope with the master password, so a verified proof is a
server-verifiable proof of the master password — a stolen session, a leaked
app password, or XSS in an unlocked tab no longer suffices, because the
session key is non-extractable and decrypt-only and so cannot sign.

- `#[VaultKeyProofRequired(binds, subject, purpose)]` declares the guard on a
  method; the binding lives on the attribute because the middleware cannot
  read the request body (the framework decodes JSON and drops the raw bytes),
  so the proof commits to NAMED parameters, hashed individually in order.
- `VaultKeyProofMiddleware` enforces it: reads the attribute by reflection,
  resolves the subject suite, collects the bound params, delegates to the
  service, and maps a failure to 403 `key_proof_required`. It consults no auth
  backend and honours no token scope, so it is not waived for SSO/app-password
  sessions — its authority is key material, not the login method.
- `VaultKeyProofService` issues a STATELESS, expiring, HMAC-authenticated
  nonce (no ICacheFactory — a null cache on a default install would break the
  flow) and verifies an RSASSA-PKCS1-v1_5 SHA-256 signature. Replay is a
  non-issue because the signature commits to the operation's parameters.
- Challenge endpoint `GET /api/v1/suites/{id}/proof-challenge` (ungated).
- Guard applied to compromiseRecovery, updatePrivateKey, complete, and the
  emergency-contact destroy. `VaultKeyProofAttributesTest` enumerates them and
  fails the build if one drops the attribute (a declarative guard fails open by
  omission), with a documented exclusion list (challenge, abort).

Service crypto and middleware dispatch fully unit-tested (valid verifies;
wrong key / altered value / tampered nonce / expired / wrong purpose / wrong
user / missing all refused; attribute dispatch, subject resolution, foreign
suite, 403 mapping). phpmd clean. The client half (proveMasterPassword +
wiring the four flows) lands next — until then the guarded routes 403 by
design.

Refs #673
Assisted-by: ClaudeCode:claude-opus-5
…#673)

The client half of the guard. `proveMasterPassword` (reauth.js) decrypts the
suite envelope with the freshly entered master password, re-imports the PKCS#8
bytes for SIGNING (RSASSA-PKCS1-v1_5 SHA-256 — a distinct capability from the
session key, which is non-extractable and decrypt-only and so cannot sign),
signs the challenge bound to the operation's parameters, and discards every
derived key. `keyProof.js` fetches a challenge and returns the two proof
headers, so the four flows do not each re-implement it.

Wired the flows that already hold the master password, so they keep working
against the now-guarded routes:
- compromise-recovery START — proof over the OLD key (old password) bound to
  the new key material;
- migration COMPLETE on the initiate path — proof over the NEW key (new
  password) bound to the migration id;
- routine password change (updatePrivateKey) — proof over the current key
  (old password) bound to the new envelope; the old key is already
  materialised there, so no extra prompt.

Tested: proveMasterPassword round-trips under RSASSA-PKCS1-v1_5 (verifies over
the exact server-rebuilt message; wrong password throws before signing; a
changed bound value fails verification), and the session key is pinned
non-extractable / decrypt-only. Store tests still green. prettier + eslint
clean (0 errors).

REMAINING (tracked in tasks §4.7-4.8): the emergency-contact delete and the
resume-path completion both need a master-password prompt at the point of
action (no password in hand there), plus the 403 re-enter-and-retry UX. Until
those land, those two paths return 403 by design.

Refs #673
Assisted-by: ClaudeCode:claude-opus-5
… §4.7-4.8)

Finishes the client wiring so all four guarded flows work end to end.

Emergency-contact delete (§4.7): `emergencyAccess.revoke(id, masterPassword)`
builds a proof (subject active, bound to the contact id) and the delete
carries it. `EmergencyAccessView` gained a master-password confirm dialog —
deleting a contact destroys its recovery envelope, so it must prove the
master password, which is why a session alone can no longer do it.

Completion (§4.8): completion's proof is now over the OLD (retiring) key
rather than the new one, via a new middleware subject `migrationOldSuite`
that resolves the migration's old suite. Both suites are active at completion
so 'active' was ambiguous, and — the point — the old key is the one BOTH the
initiate and resume paths already hold the password for, so a resumed run
finalises with no extra prompt. The "Finish anyway" acknowledgement path
builds the proof from the retained (or re-entered) old password, and the form
re-shows the password field on a `key_proof_required` refusal — the
re-enter-and-retry UX.

Coverage and middleware tests updated for the new subject; the middleware
gains SuiteMigrationMapper to resolve the old suite. Backend + frontend tests
green (79 PHP incl. the new migrationOldSuite resolution test; 22 frontend
incl. proveMasterPassword). phpmd clean; prettier + eslint 0 errors.

Refs #673
Assisted-by: ClaudeCode:claude-opus-5
… §7)

§6.3: VaultKeyProofCrossImplTest verifies a signature produced by the
browser's scheme (WebCrypto RSASSA-PKCS1-v1_5 SHA-256, the one
proveMasterPassword uses) with PHP openssl_verify over
VaultKeyProofService::signedMessage — proving the two implementations agree on
both the signature scheme and the message construction, the one interop risk a
same-language test cannot catch. A tampered bound value breaks it. Fixture at
tests/fixtures/vault-key-proof.json, regenerated by
generate-vault-key-proof-fixture.mjs.

§7: documented the guard in docs/ARCHITECTURE.md §4.2 — the guarded-route
table, the attribute contract, the load-bearing design points (sign-not-
decrypt; stateless nonce; not waived for any session type; complete proves the
old key; abort deliberately unguarded), and the rule that a new destructive
route MUST be added to VaultKeyProofAttributesTest. Confirmed gate-110 does not
apply (no migration, info.xml version unchanged).

Change now at 42/47. Remaining: 6.5/6.6 (a full request-pipeline / live
without-proof assertion — belongs with the §7.6 live reproduction and a Newman
e2e), and the human submission steps (§7.1 CI gates, §7.5 PR disclosure, §7.6
independent verification).

Refs #673
Assisted-by: ClaudeCode:claude-opus-5
Adds a component test for the master-password gate on emergency-contact
revocation: clicking Revoke opens the confirmation without calling the store;
confirming passes the entered password through to store.revoke (which builds
the proof); a key_proof_required refusal is surfaced and the dialog stays open
to retry; a successful revoke closes it. There was no prior EmergencyAccessView
test, so this is a new file rather than an extension.

Refs #673
Assisted-by: ClaudeCode:claude-opus-5
CI quality checks flagged three things the local per-file runs missed:

- phpmd: `VaultKeyProofMiddleware::afterException` has unused `$controller`/
  `$methodName` (mandated by the Middleware override) — suppressed with the
  same annotation MigrationController uses. Adding VaultKeyProofService pushed
  `EncryptionSuiteController` to coupling 13 — suppressed with justification,
  as two sibling controllers already do.
- phpcs: `VaultKeyProofService` called its own `b64url()`/`mac()` with
  positional args (the codebase requires named params for internal calls), and
  the `EncryptionSuiteController` constructor docblock was missing the
  `$proofService` @PARAM. Full `lib/` is back to 0 errors.
- test:l10n / l10n-parity: the 8 new UI strings (abort control, emergency
  revoke dialog, re-auth field) were added to `l10n/en.json` and seeded into
  all 36 required locales. Non-English values are English placeholders pending
  Transifex, consistent with how new source strings enter the pipeline.

Refs #673
Assisted-by: ClaudeCode:claude-opus-5
…stener (#673)

The coverage-baseline guard failed because new code in MODIFIED files was
untested, dropping their coverage against the merge base:
- EncryptionSuiteController::proofChallenge had no test — added three (issue
  on a valid purpose; 400 on an unknown purpose; 404 on a foreign suite);
- MigrationWorkService::countCommitted was only ever mocked (in
  MigrationServiceTest), so its body was uncovered — added a direct test
  summing the new-suite rows across the three stores, plus the zero case;
- SuiteMigrationAbortedListener (a new file) gained a test: it unlocks the
  SecretRequests keeping the old suite, and ignores other events.

Refs #673
Assisted-by: ClaudeCode:claude-opus-5
The l10n/*.js browser catalogues are compiled from l10n/*.json, so the 8 new
UI strings left them stale (check:l10n-js failed). Ran `npm run l10n:build` to
regenerate all 37; the diff is purely additive and prettier-clean.

Refs #673
Assisted-by: ClaudeCode:claude-opus-5
…ds (#673)

Three mechanical gates were red on the guard-hardening change:

- gate-46 (spec-anchor-existence): the abort @SPEC anchors pointed at
  openspec/specs/encryption-suites, but that requirement lives in the
  not-yet-archived change delta. Repoint the six abort anchors to
  openspec/changes/harden-vault-key-material-guards/specs/... so they resolve.

- gate-16 (spec-coverage): add the missing @SPEC tags on
  VaultKeyProofService::issueChallenge/verify/signedMessage and on
  EmergencyAccessView's cancelRevoke.

- gate-13 (modal-isolation): the revoke-confirmation NcDialog was written
  inline in EmergencyAccessView. Extract it to src/dialogs/EmergencyRevokeDialog.vue
  per ADR-004. The guard state (target id, busy flag, refusal message) stays
  with the view; the dialog is presentational and passes the entered master
  password back through its confirm event.

Assisted-by: ClaudeCode:claude-opus-5
Bring #677's guard code into this branch so #674 builds on top of it
rather than re-touching the overlapping migration / emergency-access
surfaces independently. Per the issue owner's decision to stack #674 on
#677 rather than branch from development.

Assisted-by: ClaudeCode:claude-opus-5
…ke dialog (#673)

vue/attributes-order requires the two-way binding to precede plain prop
bindings; the extracted EmergencyRevokeDialog had :open first, failing the
lint-check and Vue Quality (eslint) CI jobs. Reorder only — no behaviour change.

Assisted-by: ClaudeCode:claude-opus-5
…on (#674)

Backend of migrate-emergency-access-on-rotation, tasks 1.1–1.6 and 4.1–4.2.

A compromise-recovery rotation used to invalidate every emergency-access
recovery envelope. But the owner holds the new private key mid-rotation and can
fetch the grantee's certificate, so the envelope can be MIGRATED, not destroyed:
the browser mints a fresh envelope escrowing the new key and posts it here.

- New endpoint POST /api/v1/migrations/{id}/emergency-contacts/{contactId}
  (MigrationController::reEnvelopeEmergencyContact), owner- and old-suite-scoped
  through the same requireOwnMigration guard as the other migration writes, and
  deliberately NOT routed through commitRecord: emergency contacts are outside
  the completion gate (design D2), so a contact the browser cannot carry is left
  on the old suite for the sweep, never recorded as a gate-blocking failure.
- EmergencyEnvelopeInvalidationService::reEnvelopeForRotation re-points the
  contact to the new suite, keeps it `granted`, clears any invalidated reason,
  and audits a (re-)grant. The grantor cannot open the envelope, so it is
  shape-checked (parses, v/alg, non-empty ciphertext fields) and the declared
  grantee suite is asserted to be the grantee's CURRENT active suite — an
  envelope sealed to a stale grantee key would be unopenable.
- invalidateForGrantorRotation is now documented at its call site as a residual
  SWEEP: it finds only the contacts the loop could not carry (unreachable
  grantee), because migrated ones no longer sit on the old suite.

Tests: the re-point service (re-point + granted + reason-cleared + audit; foreign
grantor / wrong suite / missing contact / malformed envelope / suite mismatch /
grantee without an active suite; residual sweep touches only old-suite rows) and
the controller endpoint (success, 400 missing params, 409 terminated, 403/404/400
exception mapping). Existing MigrationController / EmergencyAccessService tests
updated for the new constructor dependency.

Assisted-by: ClaudeCode:claude-opus-5
…#674)

Backend of the destructive-revocation safeguard, tasks 4b.1–4b.2.

Revoking a user suite deletes its emergency-access recovery envelopes outright
(the revocation listener runs clearForGrantorRevocation), and revocation is the
last-resort route for an owner who lost their master password — exactly the
owner most likely to still need their emergency contact. Today that deletion is
silent.

- EncryptionSuiteController::revoke gains an acceptEmergencyLoss flag. While a
  usable (non-invalidated) emergency contact exists and the flag is not set,
  revocation is refused with 409 and the COUNT of usable contacts — never their
  identities, which stay grantor-private. The guard sits before revokeSuite,
  because the envelope clear happens asynchronously in the revocation listener
  downstream of the event that call dispatches; gating any later would be too
  late.
- EmergencyEnvelopeInvalidationService::countUsableForGrantorSuite counts the
  non-invalidated contacts bound to the suite.

Tests: refusal returns the count and never reaches the service nor discloses
identities; the override proceeds and clears; the no-contact case revokes
unchanged; the count excludes invalidated contacts. Existing EncryptionSuite-
Controller tests updated for the new constructor dependency.

Assisted-by: ClaudeCode:claude-opus-5
…oke (#674)

Frontend of migrate-emergency-access-on-rotation, tasks 2.x / 3.x / 4.3 / 4.5
/ 4b.3, plus the residual-surfacing and revoke-safeguard UI.

- initiateCompromiseRecovery now re-envelopes emergency contacts BEFORE
  completion (migrateEmergencyContacts): for each non-invalidated contact the
  browser fetches the grantee's current certificate, builds a fresh envelope
  escrowing the new private key, and posts it to the re-point endpoint. A
  grantee with no reachable certificate or a transient failure is collected as
  residual, never fatal — emergency contacts are outside the completion gate.
  The new private key PEM only ever leaves as envelope ciphertext (ADR-003).
  Resume cannot re-envelope (it holds only a non-extractable session key), so it
  keeps the pre-change invalidate-and-prompt fallback, as the design accepts.
- CompromiseRecoveryForm surfaces the residual: it names exactly the contacts
  that could not be carried and prompts re-establishment, and shows nothing when
  every contact migrated.
- The suite-revoke UI (App.vue) now carries the destructive-revocation
  safeguard: revokeSuite sends acceptEmergencyLoss; on the server's 409
  emergency_access_present refusal the UI shows the count and the retrieve-first
  warning, and the confirm button escalates to an explicit
  "Revoke and delete emergency access".
- Eight new UI strings seeded into en.json and all 36 locales (English
  placeholders); l10n/*.js catalogues rebuilt. ARCHITECTURE.md documents
  emergency contacts as a migrated store and invalidateForGrantorRotation as a
  residual sweep.

Tests: the re-envelope loop (reachable → post + no residual; unreachable →
residual + no post; invalidated skipped; per-contact failure isolated; index
failure safe), the residual prompt, and the revoke store contract (flag carried,
409 refusal propagated without evicting the cache).

Assisted-by: ClaudeCode:claude-opus-5
Backend, frontend, surfacing, revoke safeguard, l10n, gates and docs are done and
verified. 4.4 (cross-impl sanity) is substantially covered by the service test's
JS-shaped envelope; 4.6 (two rotations) composes by construction — both noted as
optional follow-ups. 5.5 (PR description) is pending the human-opened PR.

Assisted-by: ClaudeCode:claude-opus-5
…he test edits

Two CI failures on the PR against development:

- Frontend Check (format): the two test files I added cases to were not
  prettier-formatted (I checked the new files but not these edits). Reformatted;
  logic unchanged.
- Hydra Gates gate-16: inserting cancelRevoke before handleRevoke pushed
  handleRevoke's docblock above cancelRevoke, so handleRevoke lost the docblock
  directly above it and gate-16 read it as missing @SPEC. Reordered so each
  method carries its own docblock.

The remaining two gate-16 findings (compromiseRecovery, updatePrivateKey) are
#673's guard-attributed methods: they enter scope only when the PR is diffed
against development (not against feature/673), and a gate blind spot on the
closing `)]` of a multi-line #[VaultKeyProofRequired(...)] attribute then misses
their docblock. They pass when the PR is based on feature/673; the clean fix is
to merge #673 into development first.

Assisted-by: ClaudeCode:claude-opus-5
The 0.3.2 release bumped the version on main. Without this,
development stays behind main and the next development -> main promotion
conflicts on the version file.

Version files resolve to development's side, which is the higher line,
so this never moves a version backwards.
…60912202807

chore(release): 0.3.4-unstable.20260912202807
chore(release): sync main back into development
…260913184350

chore(sync): carry beta back into development
Wilco's Strict-review blocker #2: suites/{id}/revoke was the one destructive
route outside the guard — session-only, hard-deletes ShareTargets, promotes
delegations, blocks every secret read, and reinstate is admin-only, so a stolen
cookie could inflict the exact #395 lockout this change exists to close.

- New purpose VaultKeyProofService::PURPOSE_REVOKE_SUITE + PROOF_PURPOSE.REVOKE_SUITE.
- EncryptionSuiteController::revoke gains #[VaultKeyProofRequired(binds: ['reason'],
  subject: 'routeParam:id', purpose: PURPOSE_REVOKE_SUITE)] — the verifying key is
  resolved from the suite being revoked, so a re-aimed id breaks the signature, and
  the reason is bound so a captured proof can't be replayed against another request.
  Added to VaultKeyProofAttributesTest so a future drop of the attribute fails CI.
- Frontend: revokeSuite(reason, masterPassword) signs the proof and attaches the
  headers; the revoke confirmation now asks for the master password (which signs
  and is never sent). A stolen session, lacking the master password, can no longer
  revoke. An owner who has LOST the password uses the separate admin recovery path
  (to be designed in its own PR), never this one.

Both @SPEC tags kept on the touched methods (retrofit + the new vault-key-proof
requirement): a deleted @SPEC would trip gate-16's whole-file re-evaluation, which
mis-reads the multi-line #[VaultKeyProofRequired] attribute on the other guarded
methods (the checker bug noted on #678) — kept additive to avoid it.

Assisted-by: ClaudeCode:claude-opus-5
rubenvdlinde and others added 30 commits September 26, 2026 13:47
feat(parity): capability matrix for keepiq, 191 rows against six competitors
fix(parity): apply round 3 cross-lane corrections
fix(parity): drop the stale #184 gap from the sharing-11 note
…gnal rows, sources per system (work in progress)
feat(parity): source reads of Bitwarden, Passbolt, Vault and Nextcloud Passwords, sources and 38 demand rows
… first use) (#763)

nextcloud-vue 2.57.1 imports Dexie lazily in openDb(), so keepiq-main.js no
longer embeds Dexie; it now lives only in a separate lazy chunk. The lockfile
also moves postcss 8.5.26 to 8.5.28 (a transitive refresh).
…ly (#765)

@conduction/nextcloud-vue 2.57.1 imports Dexie on first use of the offline
database, so this app's main bundle no longer carries it. The exact pin and
the Dependabot ignore existed to keep every app on one Dexie version because
every page evaluated it; the pin goes back to a caret range and Dependabot
may bump dexie here again.
…11 changes (#767)

Gap decisions for all 91 keepiq rows (build 49 in 31 changes, defer 24, decided no 18), the matrix edits for them, and the first 11 OpenSpec changes. Specs only.
… changes (#769)

Ten admin and apps OpenSpec changes for 14 keepiq gap rows, with those rows specified in the matrix. Specs only.
…, crypto and sharing changes (#781)

Ten audit, clients, crypto and sharing OpenSpec changes for 14 keepiq gap rows, with those rows specified in the matrix and the clients-01 note corrected. Specs only.
… cache (#798)

The repo carried two Docusaurus sites. Only docs/ is built: the shared
documentation workflow defaults to source-folder "docs", so docusaurus/
was template scaffolding from 2026-03-30 that never deployed. Its CNAME
pointed at keepiq.app, which has no DNS record.

docs/.docusaurus/ (26 files) was Docusaurus' build cache, committed with
absolute paths from a developer machine. It is now untracked and ignored.

README, CONTRIBUTING, .prettierignore and the code-quality comment no
longer describe a second tree. CONTRIBUTING's release steps now match
documentation.yml (development branch, Cloudflare Worker).
Round 3 of the Strict review on #712.

The two guards I added to Application::register() in round 2 (the gate on the
prelude's return value and recordFailure() in the Bootstrap catch) had no unit
seam, and both survived mutation. That made the PR's "every guard turns a test
red" claim wrong again. Between them sat a third silent path: an enabled
OpenRegister with no loadable AppHost\Bootstrap. Examples are an OpenRegister
older than AppHost, or a partial deploy. The nested `if` just ended, nothing
was recorded, and /api/health and /api/metrics answered 500 with nothing in
the log.

OpenRegisterAutoloader::bootstrapAppHost(callable $bootstrap) is now the whole
of the wiring. It runs register() and does nothing when that refuses. It
checks inside its try that Bootstrap is loadable, so a missing class is
recorded, and a ParseError from a truncated Bootstrap.php no longer escapes
from class_exists() to abort every registrar below it. It then runs the
closure and records any throw. Application makes one call and passes a
closure that calls Bootstrap::register(). The test-only parameters
(IAppManager, class name) reach every branch.

psalm needed the class_exists() narrowing in Application to accept the
Bootstrap call, so a declaration-only AppHost\Bootstrap stub is added for
psalm and phpstan. It is never loaded at runtime or by the tests.

Also from round 3:
- a disable after registration now takes the loader off the chain, instead of
  only answering false;
- the Requirement body lists "enabled but missing from disk" among the quiet
  states, as scenario 3 and the code already did, and names the new cases;
- "logged once at boot" reads "once per request" in the test docblock too.

Mutation-checked, 6 mutations, all red in OpenRegisterAutoloaderTest:
ignoring register()'s answer, dropping the class check, moving it outside the
try, not recording, never running the closure, and leaving the loader after a
disable. Not covered by a unit test: deleting the single bootstrapAppHost()
call in Application. That breaks every AppHost route, which the Newman
collection checks. Unit suite green on the host (1369, 12 skipped), psalm 0
errors, phpcs and phpmd add nothing. phpstan did not run locally.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
fix(apphost): Nextcloud 35 support — public-API OpenRegister autoload prelude + derived CI matrix (max-version 35)
* test(cli): decrypt the envelope the server really sends (red, #793)

The CLI tests faked the server with the CLI's own envelope shape. This adds
cli/testdata/machine_envelope.json, written by the real
MachineSecretEnvelopeService::serialize() over EncryptService ciphertext
with a throwaway RSA-4096 key, and a PHPUnit guard that fails when
serialize() stops producing exactly that envelope. The Go tests decrypt it
through fetchDecrypt and ci fetch and fail today with
unexpected envelope scheme "".

* fix(cli): parse encryption.scheme and ciphertext.key from the server envelope (#793)

MachineEnvelope read a top-level scheme and payload.value, which the server
never sends, so ci fetch and ci run stopped on every real secret with
unexpected envelope scheme "". The struct now mirrors
MachineSecretEnvelopeService::serialize() (secret, encryption, ciphertext)
and fetchDecrypt checks encryption.scheme and decrypts ciphertext.key. The
client test serves the real serializer fixture instead of the CLI's own
shape.
* fix(secrets): refuse a folder the secret's owner does not own

SecretService copied folderId from the request as is on create, update
and the application paths, so a user could file a secret in another
user's folder by its id. The folder owner's delete counts and purges the
folder's secrets without an owner filter, so a planted secret held up or
was caught by their delete.

create() and update() now check the folder with FolderOwnershipGuard
against the owner, createForApplication() against the writing user, and
the machine-token paths refuse any folder, since folders belong to users.
Without the guard wired every folder is refused. The create endpoint maps
a foreign folder to 403 and a missing one to 404.

WIP at wind-down: update() is now 103 lines against phpmd's 100 line
threshold; check:strict not yet run.

Fixes #795

* refactor(secrets): one folder ownership check shared by create, update and the application path

The keepiq#795 guard was written out three times, which put update() at
103 lines against phpmd's 100. requireFolderOwnedBy() holds the check once.

* test(secrets): wire the folder guard into SecretServiceTest (#795)

The metadata-edit test moves a secret into folder-2. With keepiq#795 a move
is checked against the owner, and an unwired guard refuses every folder, so
the suite now builds SecretService with the real FolderOwnershipGuard over a
mapper where alice owns every folder.
* test(import): a restored backup keeps every secret type (red, #749)

Runs a vault of server secrets (typeId UUIDs, no type name) through the real
serializeVault, encryptBackup, the registered backup parser and the import
store's commit, plus an older backup that stores type names. Both fail
today: every restored secret is posted without a typeId, so the server
files it under the default type.

* fix(import): restore every secret type from a backup by type id or name (#749)

A backup stores the server secret's typeId, a UUID, and the import store
only stamped a typeId for the names totp, passkey, card and identity, so
every restored secret fell back to the default type. commit() now resolves
each row's type once through typeIdResolver(): a type id the vault knows is
kept, a type name maps to the vault's type of that name for every type (a
system type wins over a custom one of the same name), and anything else
still falls back to the default. The serializer spec now uses UUID type ids
as the server sends them.

Only the restore-type half of point 3; source-row numbering and the other
points of #749 stay open.
…t or send (#813)

* test(export): a secret that cannot be decrypted is counted and shown (red, #794)

decryptAllSecrets() drops a secret it cannot decrypt in an empty catch, and
openExport() and openCxp() hand only the decrypted list on, so the export
file and the CXP transfer miss it without a word. These tests decrypt three
secrets with one throwing, and expect the skipped count to reach both
dialogs, the warning to render, and Export and Send to wait until the user
continues. They fail today.

* fix(export): show how many secrets could not be decrypted before export or send (#794, wip)

decryptAllSecrets() now returns { secrets, skipped } and counts every secret
it cannot decrypt instead of dropping it in an empty catch. openExport() and
openCxp() pass the count to ExportDialog and CxpTransferDialog, which show
it in a warning and keep Export and Send disabled (and their handlers
refuse) until the user chooses to continue without those secrets. Cancel and
Close stay available.

WIP: the three new strings are not yet in l10n/*.json and l10n/*.js, so
test:l10n and the locale parity check fail on this commit.

* i18n(export): translate the skipped-secrets warning into every locale

Adds the three keepiq#794 strings to all 37 locale files (English fallback
for rm, lb, ga and mt, as the earlier encryption-suites strings did) and
regenerates the .js catalogues with l10n:build.

* docs(export): tag onUpdateOpen with the export-in-silence requirement (#794)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants