Release: merge beta into main - #515
Closed
rubenvdlinde wants to merge 889 commits into
Closed
Conversation
/opsx-verify found the machine secret-request surface non-functional on its
happy path. A live probe of POST /api/v1/app/secret-requests -- provision a
keypair, register, approve, sign an RS256 assertion, exchange it -- returned
400 and then 422. All 11 controller tests and 9 service tests passed throughout,
because they mock the collaborators and never reach the code that failed.
Two layers, both fixed:
1. SecretService::createByApplication refused the keyless Secret shell that a
secret request must create ("A secret requires a name and a key"). The shell
has no value by design: a human supplies it later and the server must never
hold a plaintext (ADR-003). On the user side the client encrypts a
placeholder, so `key` is ciphertext and the case never arose. Now an explicit
`allowUnfilled` opt-in permits an empty key for that one caller; a missing
NAME is still an error, an ordinary write-back still cannot store a valueless
secret, and the message is unchanged for existing callers.
2. With validation passing, Postgres rejected the row: null value in column
"key" violates not-null constraint. Secret::$key defaults to '' and the
Nextcloud Entity setter marks a field dirty only when the value CHANGES, so
setKey('') on a fresh entity is a no-op and QBMapper omitted the column
entirely. Version000033 gives the column an empty-string default, matching
the entity contract. Structural rather than call-site because any future
insert leaving `key` at its entity default would fail identically, and a
caller cannot mark a field dirty.
Also corrected: fillLinkUrl pointed at the JSON API route, so an application
doing what the field name says -- hand the link to a person -- sent them raw
JSON including the vault's public certificate. It now resolves to the anonymous
SPA shell (verified: that form answers 200, /apps/doriath/share/request/{token}
answers 401), with fillApiUrl retained for machine polling.
INTEGRATION COVERAGE WAS DEAD, NOT MISSING
tasks.md required extending the machine-secret-api Newman collection, and two
spec scenarios cited it as their @e2e exclude justification. Beyond not being
written, the existing seeded folders could never have run: they sign RS256
assertions in a prerequest script via require('crypto'), which newman's sandbox
does not provide. Every seeded test took its pm.test.skip branch and the run
reported green -- locally and in CI. run-newman.sh now signs assertions where
real node exists and injects them; the collection prefers the injected value.
Section 6 adds 9 requests (create, list, inert userId, three input negatives,
public fill endpoint, jti replay), all verified executing and passing against a
live instance. Note this switches on sections 4-5 too, which have never run --
the openconnector-side CI may surface further findings.
Spec citations corrected: four cited test methods did not exist under those
names, one test name claimed a case its body never exercised (the
non-approved-application half is enforced at authentication, JwtAuthService
:221), and the DI-seam requirement still named SecretRequestService after the
extraction.
KNOWN LIMITATION, documented not hidden: the anonymous fill page renders blank
in a headless browser. It is not specific to this feature -- every route on the
/public shell renders empty for an anonymous visitor, on a freshly rebuilt
bundle -- so it is pre-existing and needs its own issue. The API surface is
fully working; the human hand-off leg is not.
Verified: 50 hydra gates green (gate 4 does not run locally), PHPUnit 982/3155,
vitest 551/551, phpcs 0 errors, php-cs-fixer clean, openspec validate --strict
passes, newman section 6 9/9 executed and passing.
Refs: #120
Assisted-by: ClaudeCode:claude-opus-5
Synced the delta specs into the main specs before archiving, because archiving
moves the deltas out of the active tree and none of the five requirements had
landed yet:
secret-requests + Session-less Application-Initiated Request Creation
secret-store-api + Machine Secret-Request Creation
+ Machine Pending-Request Listing
+ Machine Request Creation Own-Vault Scoping
+ Machine Request Creation Hardening and Audit
The secret-requests requirement is inserted inside ## Requirements, before
## User Stories, rather than appended at EOF where it would have landed under
## Notes.
Archived to openspec/changes/archive/2026-08-17-application-secret-request-creation.
Verified after the move: the 8 @SPEC anchors in the new code now point into the
archive path, and all 50 gates still pass — the anchor gate tolerates archived
changes by design, which ~450 pre-existing anchors already relied on.
Not done, deliberately: no CHANGELOG.md was created. The archive workflow would
have created one starting at 0.2.28 containing only this change, which
misrepresents a project with dozens of archived changes behind it. Worth doing
as its own commit if changelogs are wanted going forward.
Pre-existing and untouched: openspec validate --specs reports
spec/encryption-suites failing (requirement 7 lacks SHALL/MUST). Both specs
touched here validate clean.
Refs: #120
Assisted-by: ClaudeCode:claude-opus-5
Every route on the /public shell rendered an empty page for an anonymous visitor. Secret-request fills, link shares and ephemeral sends were all affected: a recipient without a Nextcloud account could not complete any public flow. Verified in a headless browser, and it reproduced on a freshly built bundle, so it was not stale assets. Root cause is a layout difference, not a routing one. NcContent (rendered by CnAppRoot) teleports its accessibility skip-link into `#skip-actions`. Nextcloud's AUTHENTICATED layout provides that element; PublicShellController renders with RENDER_AS_BASE, whose layout does not. Confirmed directly: the served HTML contains `skip-actions` once when authenticated and zero times on /public. Vue treats a null Teleport target as a hard error during the component update: [Vue warn]: Failed to locate Teleport target with selector "#skip-actions" [Vue warn]: Invalid Teleport target on mount: null Uncaught TypeError: Cannot set properties of null (setting '__vnode') That aborted the update before CnAppRoot::mounted() reached the `finally` that clears `capabilitiesLoading`, so the shell sat on its loading spinner forever and the <router-view> never rendered. The DOM showed exactly one child: `cn-app-root__capabilities-loading`. The guard creates the element only when the layout did not, so the authenticated page is untouched and no duplicate id appears. It is prepended to <body> because a skip link that is not the first focusable element satisfies the Teleport but defeats its own purpose. Placed at bootstrap rather than in the template because the target is a client-side requirement of the component tree; doing it once covers every controller that renders the shell with a non-user renderAs instead of relying on each to remember. Verified after the fix: the anonymous fill page renders "Fill in secret" with its requested fields and a Submit button, the spinner is gone, and there are no page errors. The four new tests were checked against an injected defect (a guard that does nothing): 3 of 4 fail, the fourth legitimately still passes because it covers the already-exists path. Two things noticed while verifying, NOT addressed here because they are UI decisions rather than this defect: the anonymous page renders the full app navigation (Dashboard, Vault, Certificates, Settings) and a "Support Doriath" donation block, neither of which belongs in front of an external recipient, and the fill inputs report no aria-label. Verified: vitest 555/555 (80 files), eslint clean, prettier clean. Assisted-by: ClaudeCode:claude-opus-5
Three defects in the request-creation dialog, all found while verifying the machine surface end-to-end. THE LINK DID NOT WORK FOR ITS RECIPIENT The dialog built /apps/doriath/share/request/<token> and told the user to share it. That URL answers 401 for anyone without a Nextcloud account — precisely the person a fill link exists for. It now points at the anonymous shell, /apps/doriath/public#/share/request/<token>, verified rendering the fill form. The same defect was fixed on the machine surface in #265; this is the user-side half. Two tests asserted the broken URL, so they were asserting the bug. CANCEL LEFT THE PREVIOUS SECRET'S LINK IN THE DIALOG The form reset was conditional on the "Done" button. Create a request, press Cancel, reopen the dialog on a DIFFERENT secret, and it still showed the previous secret's fill link, ready to copy and send to the wrong person. The reset is now unconditional. With that, the `emitDone` argument controlled nothing, so it is gone rather than left implying a difference that does not exist — parents already learn of success from the `created` event. THE FIELD LIST DID NOT MATCH WHAT THE BACKEND ACCEPTS `url` was never offered even though secrets have always stored it, and an additional field could only be requested when the secret ALREADY carried that key — so a fresh or unfilled secret could not ask for a named extra at all, while the machine API accepted any name. Both are closed: `url` is offered and labelled "stored unencrypted" (it is searchable plaintext, and someone typing a value in deserves to know), and a name can be typed for a field that does not exist yet. Built-in names are refused there rather than silently accepted, because "key" typed as a custom field would be routed to the ciphertext column and the user would be requesting something other than what they typed. The 6 new source strings are translated into all 36 locales, matching what this repo does for new strings (64462ca put real translations in every locale rather than English placeholders) and keeping the no-regression parity ratchet green. Dutch, German, French, Spanish, Italian and the other major locales I am confident in; Romansh, Luxembourgish, Maltese, Irish and Belarusian are my best effort and worth a native check when Transifex next syncs. `l10n/en.json` also gained "Version history entry", a pre-existing unregistered string the extractor picked up — kept because removing it would re-break the source check. Verified: vitest 560/560 (80 files), eslint 0 errors, prettier clean, both l10n checks pass. tests/vitest/rsa-cross-runtime.spec.js failed once mid-run under load and passes in isolation; it is untouched by this change. Assisted-by: ClaudeCode:claude-opus-5
gate-16 requires @SPEC on changed methods. availableFields and addCustomField implement the Requestable Fields requirement, so they carry real anchors. onClose carries a documented exclude: no requirement describes when a dialog resets its local state, and inventing an anchor to satisfy a gate would make the annotation a lie. Assisted-by: ClaudeCode:claude-opus-5
… to have Artifacts only -- no implementation. The human secret-request flow violates a MUST that has been in the spec since March: :12 "Doriath creates an unfilled Secret (a placeholder with no key value)" :46 "THEN the system MUST create an unfilled Secret and a SecretRequest" :222 "Each new SecretRequest creates its OWN unfilled Secret" What ships instead: the only entry point is SecretDetail.vue, SecretRequestCreateDialog requires a `secret` prop, and SecretService::create() throws on an empty key -- so asking a colleague for a credential means first inventing a value for the thing you do not have. The machine surface added in #120 already complies via allowUnfilled, so the human path is the outlier. The already-filled-fields complaint turned out to be the same bug. Because a fresh request must aim at an existing, possibly populated Secret, and :147 requires every requested field to be submitted non-empty, a requester can compel a recipient to overwrite a good value. Under the spec's model that cannot arise: a fresh request targets a Secret that is empty by construction. Two genuine requirement additions, the rest is correction: secret-requests MODIFIED Create Secret Request -- a fresh request does not name an existing Secret and the system creates the placeholder (previously only implied by the Purpose section and an acceptance criterion, which is how the implementation drifted without failing a scenario) ADDED fresh requests do not re-ask for existing values, decided client-side at creation and never disclosed to the recipient secrets ADDED the keyless exception: a Secret MAY have an empty key ONLY while a pending request targets it, as an explicit opt-in that is never the default That last one follows Robert's framing: a key IS required for secrets with no request tied to them, so this is an invariant with a boundary rather than a relaxed validation. The `secrets` spec never required a non-empty key at all -- the implementation invented that constraint, which is why the two capabilities currently contradict each other. Deliberately left open and documented rather than smuggled in: an expired request stays `pending`, so its placeholder persists as a permanently empty Secret until revoked. Auto-revoking on expiry changes the Optional Expiry requirement and belongs in its own change. Ordering: implementation must follow PR #265, which carries Version000033 (empty-string default on doriath_secrets.key). Without it every keyless insert dies on the NOT NULL constraint. 18 task checkboxes (cap 20), acceptance criteria as plain bullets. Verified: openspec validate --strict passes for the change and for both touched specs. The one failing spec repo-wide (encryption-suites, requirement 7 lacks SHALL/MUST) is pre-existing and untouched. Assisted-by: ClaudeCode:claude-opus-5
…ret-request-creation feat(secret-store-api): session-less secret requests for applications
…cycle Artifacts only. Extends request-first-secret-requests with the outstanding-request indicator and expiry handling Robert asked for. CORRECTION TO MY OWN FRAMING I said there was no expiry mechanism. That was wrong. Expiry IS enforced: SecretRequestPolicy::requireOpenByToken() calls isExpired() and answers 408 once expires_at has passed. What is missing is narrower -- nothing POPULATES the field and nothing acts on it asynchronously. The limitation I wrote into design.md yesterday also named the smaller of two litter sources (expired-but-pending) rather than the larger one (requests that never had an expiry), which I would have caught by tracing expires_at before writing it. Tracing it is what shaped this: expires_at has exactly one source, a requester typing into an optional datetime-local input that defaults to empty. So a job that only sweeps expired requests would be nearly inert, and the litter sits in the population the job must not touch. WHAT THE ARTIFACTS NOW SAY Expiry is set: the create surface pre-fills a suggested expiry the requester can change or clear. A client-side default, not a policy -- no server semantics, no admin setting, no migration, and still spec-legal because the requester remains the one setting it. rotation-expiry-policies holds the heavier pattern (instance default shipped disabled + per-user override) for a later change. Expiry acts: a TimedJob transitions lapsed pending requests to a new terminal STATUS_EXPIRED, invalidating the token and deleting the placeholder -- the same cleanup revoke performs, because the keyless invariant does not care why the request ended. Requests with NO expires_at are never touched. Follows ExpireMachineLeasesJob (hourly). Expiry stays distinguishable from cancellation: a terminal status rather than a silent revoke, so a vault row never vanishes unexplained, and the audit actor is the system rather than the requester who took no action. A DEFECT THIS CHANGE WOULD OTHERWISE HAVE INTRODUCED Robert's instruction -- always check expiry on access, not only status -- caught it. The gate does check expiry, but INSIDE `case STATUS_PENDING`, and the switch ends in `default: throw ... 'Request is in an unknown state', code: 500`. Adding STATUS_EXPIRED without its own arm would answer every expired link with a 500 instead of saying it expired. The evaluation is therefore hoisted above the switch, and `expired` gets an explicit 410-family arm. The job is cleanup, never enforcement: between a request lapsing and the next hourly sweep its stored status still reads pending, so the gate must refuse on expires_at alone. The indicator is specced as required rather than polish: once asking for a credential normally produces an empty placeholder, an unmarked vault gains rows that read as broken secrets. It distinguishes "no values yet" from "re-request outstanding" (the first is unusable, the second works until new values arrive) and never carries the fill token. Delta now: 3 requirements MODIFIED (Create Secret Request, Optional Expiry, Revoke Request), 2 ADDED (no re-asking for existing values, outstanding-request indicator), 23 scenarios. 19 task checkboxes (cap 20) after merging three pairs that were one concern each. openspec validate --strict passes. Assisted-by: ClaudeCode:claude-opus-5
Per ADR-032 and Robert's call that the expiry lifecycle matters less for a beta
than getting the concept of a secret request right.
request-first-secret-requests 17 tasks -- the beta-relevant correction:
a fresh request creates its own unfilled
Secret, a vault-level entry point, no
re-asking for filled values, and the
outstanding-request indicator
secret-request-expiry-lifecycle 14 tasks -- expiry is set (pre-filled,
clearable), swept (TimedJob -> terminal
`expired`), and enforced independently of
the sweeper
Siblings, not a chain. Neither depends on the other: the expiry change operates
on requests as they exist today, and placeholder deletion is already exercised by
application-created requests from #120. Both edit
SecretRequestCreateDialog.vue, so whichever lands second absorbs a small
conflict; the expiry change touches one field, so it is the cheaper rebase.
Priority is recorded in both proposals.
The split also let the request-first tasks go back to being separate where I had
merged three pairs purely to fit under the 20-checkbox cap -- 17 now, so the
merging was cap pressure rather than genuine cohesion.
Stated rather than hidden: with the expiry change deferred, request-first ships
placeholders with no sweeper behind them, so an abandoned placeholder persists
until someone revokes it. Revoke does clean up (:189) and a lapsed request is
still refused on access, so the cost is vault clutter, not a correctness or
security gap. That is an accepted consequence of the ordering, now written into
request-first's Impact and Risks.
Also carried into the expiry change and worth keeping visible: hoisting the
expiry evaluation above the status switch is load-bearing, not tidying. The
switch ends in `default: 'Request is in an unknown state', code: 500`, so adding
STATUS_EXPIRED without its own arm would answer every expired link with a 500.
And the job is cleanup, never enforcement -- between a lapse and the next hourly
sweep the stored status still reads `pending`, so the gate must refuse on
expires_at alone or the job's schedule becomes part of the security boundary.
The concrete pre-filled interval is deliberately left open in both spec and
tasks: it is a product call, not an implementation detail to bury.
Verified: openspec validate --strict passes for both changes and for
secret-requests; 17 and 14 checkboxes, both under the cap; criteria are plain
bullets.
Assisted-by: ClaudeCode:claude-opus-5
#267 request-first-secret-requests 17 tasks feature/267/... #268 secret-request-expiry-lifecycle 14 tasks feature/268/... Base branch is `development` (the repo default). Labels openspec + doriath + one per delta spec; all four already existed, so none were created. No trigger label: there is no HYDRA_LABEL_PREFIX in ~/hydra/secrets/.env, so neither issue is queued to Hydra. The beta priority is written into the issue bodies rather than left in a conversation: #268 states it sits below #267 and that nothing in #267 waits on it, so whoever picks these up does not start with the wrong one. #268 also carries the two things most likely to be missed when reading its tasks in isolation -- that hoisting the expiry check is load-bearing (adding STATUS_EXPIRED without its own switch arm answers every expired link with a 500) and that the sweeper is cleanup rather than enforcement -- plus the one open product decision, the concrete pre-filled expiry interval. Renumbered request-first's tasks while building the plan: stripping the expiry work had left gaps at section 2 and at 3.3 / 4.4, which read as if work had gone missing. Sections are now 0-4 with consecutive sub-numbers; no content changed. Verified live: both issues carry their full checkbox set (17 / 14), the placeholder `## Branch` section resolved to the real feature branch on the second write, and each plan.json now records tracking_issue, base_branch and feature_branch for /opsx-apply. Assisted-by: ClaudeCode:claude-opus-5
…ll-anonymous-render
…ll-anonymous-render
…7/request-first-secret-requests
Backend half of #267 (tasks 0.1-1.4). Closes the MUST the human flow has been violating since March: "the system MUST create an unfilled Secret and a SecretRequest". Until now the only surface demanded a pre-existing Secret and SecretService::create() refused an empty key, so asking a colleague for a credential meant first inventing a value for it. 1.1 `SecretService::create()` takes an explicit `allowUnfilled`, default false. A NAME stays required in both modes; only the key requirement is relaxed, and only when the caller asks. The suppression mirrors #266's adjudication verbatim so this does not reintroduce the phpmd BooleanArgumentFlag failure that was just fixed on development. 1.2 `SecretRequestService::createForUserVault()` creates the placeholder and then the request, mirroring createForApplicationVault() including its rollback -- the shell exists only to receive this request. The suite id is read off the Secret the system just created rather than taken from a caller parameter, so it cannot drift from the Secret the values land in. 1.3 `secretId` is now optional on the create endpoint and a new MODE_FRESH dispatches to the above. A re-request without `secretId` is refused explicitly rather than failing deeper on a generic message, because the two flows differing by required input is what keeps them distinct. 1.4 Revoke now deletes the placeholder -- "the unfilled Secret MUST be deleted" was specified and never implemented; it only becomes reachable now that fresh requests create placeholders. The discriminator is EMPTINESS, not `isReRequest`. A plain request also carries isReRequest === false while targeting a Secret the USER picked, so keying the delete on that flag would destroy real credentials. Cleanup is fail-soft and runs after the status flip: an orphan empty Secret is a far better failure than a deleted Secret whose request is still pending. Two mistakes the tooling caught, worth recording: - I guessed `SecretMapper::find()`; it is `findById()`. The existing decline test failed immediately with "Call to undefined method", which is the value of running the suite per task rather than at the end. - Adding a public method pushed the class to 11 against phpmd's threshold of 10. Adjudicated in the class docblock rather than split, because the public surface IS the request state machine and separating the creation paths would scatter the rollback and the fresh-vs-re-request distinction the spec turns on. The change artifacts are merged in from the openspec branch: the @SPEC anchors this code carries point at the change delta, which did not exist on this branch until now. #268's artifacts ride along in that merge. Verified: 50 hydra gates green, PHPUnit 559/1696 in tests/Unit/Service, phpcs 0 errors, phpmd 0 violations on both touched services. Refs: #267 Assisted-by: ClaudeCode:claude-opus-5
…irst Frontend half of #267 (tasks 2.1-2.5). 2.1 `SecretRequestCreateDialog`'s `secret` prop is optional. With no target it asks what you want and offers a folder, requires a name, and creates the placeholder through the fresh path. Heading now names which of the three acts it is rather than calling all of them "Request secret fill-in". 2.2 Already-filled fields are no longer pre-selected, and are labelled as holding a value. Decided client-side from key/login/url and the DECRYPTED additionalFields, because the server never decrypts that blob (ADR-003) and telling the anonymous recipient which fields are filled would hand vault metadata to an unauthenticated party. A re-request is exempt: replacing values is its purpose. It matters because the recipient cannot decline a requested field, so carrying a filled one in compels an overwrite. 2.3 "Ask for a credential" added at the vault level, reachable with an EMPTY vault. Mounted with no `:secret`, which is what makes it a fresh request. 2.4 The detail action says which thing it does -- "Ask for new values" on a Secret that holds one, "Ask someone to fill this in" on an empty placeholder -- and binds isReRequest to whether a value exists, so an overwrite is only ever called an overwrite. 2.5 Rows carry an outstanding-request badge, distinguishing "waiting for someone to fill this in" (unusable) from "new values requested" (usable until they arrive). It takes a STATE, never the request: a fill token in a list row travels into screenshots for no benefit. Clears when the request stops being pending. A REAL BUG FOUND ON THE WAY, NOT PART OF THE PLAN The dialog built its payload in snake_case (`secret_id`, `requested_fields`) while the store forwarded camelCase (`payload.secretId`, ...). Every value arrived `undefined`, so the POST went out empty and the endpoint answered 400 "requestedFields cannot be empty" -- the plain create path has never worked from the UI. Proven live against both shapes before changing anything. The unit test asserted the snake_case payload against a mocked store, so it passed while the feature was broken: the same mock-hides-integration shape as the 400 in #265. That assertion is corrected and now also asserts the absence of `secret_id`. Also fixed here: an earlier insertion of mine had stranded availableFields' docblock above isFreshRequest, so it documented the wrong method and gate-16 caught the missing @SPEC. VERIFIED LIVE, not only in unit tests - fresh request -> 201, placeholder auto-created, token minted, all three field kinds echoed (key, url, zgw-client-id) - the row in the database: name set, `key` EMPTY, owner user/admin, request pending, is_re_request false - revoke -> placeholder deleted (0 rows) while all 6 filled secrets survive, which is the hazard the emptiness discriminator exists to avoid Verified: 50 hydra gates green, vitest 560/560 (80 files), eslint 0 errors, frontend build clean. Refs: #267 Assisted-by: ClaudeCode:claude-opus-5
Tests and quality for #267 (tasks 3.1-4.2). All 17 tasks now complete. 3.1-3.3 PHPUnit: create() refuses an empty key by default, accepts one with the opt-in, and still requires a name in both modes; a fresh request creates its own placeholder and two do not share one; a failed creation rolls the shell back; revoke deletes an unfilled placeholder, NEVER a filled Secret, and never reaches across an ownership boundary. Both new guarantees were checked by injecting the defect they exist to catch: dropping the key requirement fails testCreateStillRefusesAnEmptyKeyByDefault, and deleting any linked Secret on revoke fails testRevokeNeverDeletesAFilledSecret. A test that cannot fail is not coverage. 3.4 Vitest: the dialog submits with no `secret` prop and sends `name` instead of `secretId`, refuses a nameless fresh request, does not pre-select a filled field, reads member-level filled-ness from the decrypted blob, and a re-request still pre-selects the filled key. The row badge distinguishes awaiting-fill from re-request, disappears without a request, and renders no 32-hex string -- the token has no path into a list row. 3.5 Every scenario in the delta now carries a reason-bearing @e2e exclude naming the test that actually drives it. Two scenarios had no test, so I wrote them rather than cite fiction: the two-fresh-requests invariant, and SecretRequestFillControllerTest::testShowNeverDisclosesWhichFieldsAreAlreadyFilled, which asserts the fill payload as a CLOSED set so a future field cannot leak filled-ness to an anonymous recipient unnoticed. 4.1 13 new UI strings translated into all 36 locales (455 entries), keeping the no-regression parity ratchet green. Dutch, German, French, Spanish, Italian, Portuguese and the Nordic/Slavic majors I am confident in; Romansh, Luxembourgish, Maltese, Irish and Belarusian are best effort and deserve a native pass at the next Transifex sync. A MISTAKE I MADE THREE TIMES, WORTH NAMING Inserting a method by anchoring on `methodName() {` drops the new code BETWEEN an existing docblock and its function, silently reassigning that docblock -- and its @SPEC -- to the wrong method. It happened to availableFields, addCustomField and onCredentialRequested. gate-16 caught all three. Worse, one of my repair anchors matched the first `@return {Promise<void>}` in the file and landed an outstanding-request-indicator @SPEC on `onBulkDone()`, an unrelated pre-existing method; that false annotation is removed. Anchor on the docblock, not the signature. Recorded as a sync-time action rather than left to rot: openspec/specs/secret-requests/spec.md:9 carries a FILE-LEVEL @e2e exclude justified as "No secret-request UI is built in v0.1". This change builds that UI, so the justification is now false and must be replaced on sync. Verified: 50 hydra gates green, PHPUnit 993/3186, vitest 569/569, phpcs 0, phpmd 0, psalm clean, php-cs-fixer clean, eslint 0 errors, frontend build successful, both l10n checks exit 0, openspec validate --strict passes. Refs: #267 Assisted-by: ClaudeCode:claude-opus-5
…created Robert asked where the URL to fill a request comes from. It came from nowhere: the requester could not obtain it through the UI at all. The dialog builds it correctly and shows it in a read-only field with a Copy button and a Done action -- an arrangement that only makes sense if you are meant to linger. submit() sets `fillUrl` and THEN emits `created`. Both parents closed the dialog on that event, and both mount it with v-if, so the component was destroyed one tick after computing the link: SecretList::onCredentialRequested credentialRequestOpen = false (mine, 2.3) SecretDetail::onRequestCreated requestDialogOpen = false (pre-existing) The second one's docblock stated the behaviour as the intent -- "Close the dialog after the new request was created" -- so the fillUrl block and the Copy button have been unreachable code since the feature shipped. The fallback route does not work either: SecretRequestList renders the token deliberately TRUNCATED (truncateToken -> "abcd1234…"), so it cannot be reconstructed from the list, even though the API returns the full token. After creating a request the only ways to reach the link were the database and a raw API call. That means the user-side secret-request feature has never worked end to end. It is very likely part of why the flow felt wrong in the first place. Neither parent closes on `created` now; the user dismisses the dialog when they have the link. SecretDetail's handler stays as the hook, with the reasoning in its docblock so the next reader does not "tidy" the close back in. The regression test was checked by reinstating the old line: it fails with the close, passes without it. Verified: 50 hydra gates green, vitest 570/570, eslint 0 errors, frontend build successful, l10n source check exit 0. Refs: #267 Assisted-by: ClaudeCode:claude-opus-5
Two live failures Robert hit while exercising the flow, plus the copy-link action
he agreed to.
500 ON EVERY FIRST FILL — the same NOT NULL trap as Version000033, one table over
Filling a placeholder answered 500 "Unable to fulfil request". `update()` snapshots
the PRE-update row; for a placeholder that row's `key` is empty, and
SecretVersion::$key defaults to '' — so the Nextcloud Entity setter never marked
it dirty, QBMapper omitted the column, and the NOT NULL constraint on
doriath_secret_versions.key rejected the insert:
ERROR: null value in column "key" of relation "oc_doriath_secret_versions"
violates not-null constraint
Unreachable before, because no keyless Secret could be updated. Request-first made
it reachable on the very first fill of every placeholder. Fixed on both levels:
Version000034 gives the column an empty-string default, matching the entity
contract. Version000033 did exactly this for doriath_secrets.key
and should have prompted a sweep of every table with the same
shape; it did not, and this is the cost.
shouldSnapshot() skips the snapshot when the pre-update row held NO values at
all. A first fill has no earlier value to return to, so the row
would be a version saying "nothing". Narrow on purpose: a real
credential change still snapshots, which a second test pins.
Verified live end to end: create 201, fill 200 -> fulfilled, the Secret holds the
key, url and additionalFields blob, and zero junk version rows.
THE FILL LINK CAN NOW BE RECOVERED
The link was shown once and the token is rendered truncated by design, so closing
the dialog stranded the request — revoke and start again was the only remedy. The
list now offers "Copy fill link" per row, using the SAME url builder as the
dialog: src/utils/fillLink.js is the single place that string is constructed,
because the dialog has already shipped two wrong variants (the JSON endpoint, and
the authenticated path that 401s for the recipient). A second consumer cannot
reintroduce a third.
Offered only where the link would work: fulfilled, declined and lapsed requests
get nothing, and expiry is judged on `expires_at` rather than status because
nothing sweeps until secret-request-expiry-lifecycle lands. The row still shows
the token truncated; the full value reaches the clipboard only on request.
Also here: the Fill Link Recovery requirement the new @SPEC anchors referenced —
it did not exist yet, so gate-46 would have caught the dangling anchor.
NOT A BUG: 'Cannot read properties of undefined (reading importKey)'
crypto.subtle is undefined outside a secure context. Measured in a headless
browser: http://nextcloud.local -> isSecureContext=false, subtle=undefined;
https://nextcloud.local -> true, object. Robert's browser had chosen http.
Environmental, no code change.
MY OWN RECURRING MISTAKE, FOURTH OCCURRENCE
Inserting a method by anchoring on its neighbour's signature drops the new code
between an existing docblock and its function, silently reassigning that docblock.
It hit fieldsChanged this time; phpcs caught it. availableFields, addCustomField
and onCredentialRequested were the earlier three. Anchor on the docblock.
Verified: 50 hydra gates green, PHPUnit 995/3190, vitest 573/573, phpcs 0,
phpmd 0, psalm clean, php-cs-fixer clean, eslint 0 errors, build successful,
both l10n checks exit 0 (2 new strings x 36 locales), openspec validate --strict.
Refs: #267
Assisted-by: ClaudeCode:claude-opus-5
…ncrypt The fill page is the one surface handed to strangers, and outside a secure context it died on `crypto.subtle.importKey` with "Cannot read properties of undefined" -- meaningless to the recipient, and giving them no reason to suspect the URL's scheme. They cannot fix the instance, but they CAN open the https:// form of the same link, so the page now says that. Checked before every other state, because every path below encrypts in the browser. Both `isSecureContext` and the presence of `crypto.subtle` are tested: a browser could in principle report one without the other, and the failure being prevented is a raw TypeError shown to an outsider. The form is not rendered at all -- offering a submit that cannot succeed is worse than explaining why. Measured rather than assumed, in a headless browser against this instance: http://nextcloud.local isSecureContext=false crypto.subtle=undefined https://nextcloud.local isSecureContext=true crypto.subtle=object which is exactly the environment Robert's browser had chosen. No server change: the instance's overwrite.cli.url is already https, and overwriteprotocol is deliberately unset so generated links follow the scheme actually in use. The test sets isSecureContext=false and asserts both that the explanation renders and that no input is offered. 2 new strings translated into all 36 locales. Verified: 50 hydra gates green, vitest 574/574, eslint 0 errors, build successful, both l10n checks exit 0. Refs: #267 Assisted-by: ClaudeCode:claude-opus-5
…ents gate-16 flagged it and I committed 844d641 while that gate was red -- the pre-commit hook is not executable in this checkout, so nothing stopped me. The previous commit should have waited. Assisted-by: ClaudeCode:claude-opus-5
Filling a requested secret in a private browser showed the "Support Doriath" note. Those pages are opened by people who are not our users at all — someone submitting a credential we asked them for, or opening a share — and an appeal mid-task is at best noise, at worst a phishing tell on a page that is about to receive a secret. CnAppRoot documents the opt-out (`supportDialog: false`), so this is a host decision, not a library change. The first attempt did not work, and why is worth recording: I keyed it on a computed over `$route.name`. CnAppRoot reads `props.supportDialog` ONCE inside setup() to decide whether to wire the dialog at all, and that runs before the router resolves the initial navigation — so the value it read was always the pre-navigation one. Verified by measuring the rendered page, not by reasoning: the donation text was still present after the first fix. It now answers from the URL, which is correct at that moment. `isPublicSurface(location)` lives in router/guards.js beside `isPublicRoute()`, because that module already owns what "public" means and a second definition would drift: `/apps/doriath/public` is the anonymous shell, plus the recipient hash prefixes for the same routes on the authenticated shell. The library's own guard is not sufficient here. It opens the note only on a DEFINITIVE "not seen" answer, but the preference request 401s for an anonymous visitor and a 401 is not a definitive no. Verified in a headless browser on the anonymous shell: donation text absent, the fill form and Submit still present. Still there and NOT addressed, because it is a separate UI decision: the recipient page renders the full app navigation (Dashboard, Vault, Certificates, Settings), none of which they can use. Verified: 50 hydra gates green, vitest 578/578 (80 files), eslint 0 errors, build successful, l10n source check exit 0. tests/vitest/rsa-cross-runtime.spec.js failed once mid-run under load and passes in isolation; it is untouched here. Refs: #267 Assisted-by: ClaudeCode:claude-opus-5
…equests Artifacts only. An application can ask a human to submit a credential into its own vault and no person on the instance can see that it did. Measured, not inferred: created_by count admin 9 application:b177cd1a-aa48-41b6-8728-7940a6527181 2 application:446992fb-4e51-426d-adae-a6ff5eab17de 2 application:fe52a684-9d68-4feb-8041-3ea3c57801a7 2 Six invisible requests. The cause is one scoping decision: SecretRequestController::index() -> listByUser() -> findByCreatedBy(), an exact match, and application requests carry created_by = 'application:<uuid>'. The target Secrets are application-owned so they appear in no vault; the only lister is the application's own Bearer endpoint; the admin views have no request surface. Creation IS audited (6 application.secret_request_created rows), so the events exist with nowhere to read them as state. Admin-scoped rather than registrar-scoped, decided in design.md: an application's vault belongs to no single user, registration is a historical act rather than continuing responsibility, and the registrar may have left. Tying audit visibility to it would make "who can see this" depend on who clicked Register months ago. The user-side listing is deliberately left alone. A single method that sometimes means "mine" and sometimes "this application's" is how scoping bugs happen. Follows the precedent set by Machine Pending-Request Listing, which exists so a fill link is retrievable after creation for an application; this is the same argument for the administrator accountable for that application. Risks stated rather than discovered: an admin now routinely sees requested field names (plaintext by design, already audited, but a real widening of who reads them); an admin revoke can break an integration mid-flow, which is the intended power and should read as consequential; and the shared list component will serve two authorities, so the scope must come from the endpoint rather than a prop. 13 task checkboxes. openspec validate --strict passes for the change and for application-mgmt. Assisted-by: ClaudeCode:claude-opus-5
Artifacts only. An owner can SEE their additional fields but has no way to add or change one -- every layer supports them except the two dialogs an owner uses. controller / service / store encrypt+decrypt / SecretDetail render support it SecretCreateDialog / SecretEditDialog zero refs So the only writers are other actors: the write-for-application dialog, the share dialogs (pass-through), import, and a secret-request fill submitted by an EXTERNAL RECIPIENT. Today the only way an owner gets an additional field onto their own secret is to ask a stranger to put it there, or to import it. That is how it surfaced -- the request flow lets a requester name arbitrary members and the fill page stores them happily. The omission is in the specification, not only the code. Nine specs discuss additional fields; secrets-write-ui, the one capability that owns creating and editing a secret, never mentions them, and both of its requirements enumerate the collectable fields and leave this one out. Hence MODIFIED rather than ADDED. Design decisions worth the paper: reuse the request dialogs reserved-name validation rather than write a third copy of a rule that matters (a member named "key" would look like a second value field while the real key goes to its own column); refuse reserved names rather than remap them, because silently routing a member called "url" to the url column inverts the plaintext/ciphertext distinction for that value; and removing the last member yields an EMPTY blob so "none" stays distinguishable from "not loaded". Stated rather than discovered: every member edit rewrites the whole blob, so an edit from a stale decrypted copy drops members another session added. Inherent to the single-blob design the secrets and import specs deliberately require. 12 task checkboxes. openspec validate --strict passes for the change and for secrets-write-ui. Assisted-by: ClaudeCode:claude-opus-5
This spec was auto-generated when implement-secrets-write-ui was archived and
never got the Status / OpenSpec-changes block every other spec carries, so
registering a change against it had nowhere to go.
Added, with both changes listed. Its Purpose is still the generated placeholder
("TBD - created by archiving change ... Update Purpose after archive"); left as
found rather than invented here, but it is a real gap in a spec that owns the
secret create and edit surface.
Assisted-by: ClaudeCode:claude-opus-5
#271 admin-application-request-visibility 13 tasks #272 owner-editable-additional-fields 12 tasks Base branch development. Created the secrets-write-ui label, which did not exist yet -- that spec had never had a change tracked against it. Both issue bodies carry the measured evidence rather than a description: the six invisible application requests with their created_by values for #271, and the layer-by-layer table showing where additional-field support stops for #272. Whoever picks these up should not have to rediscover why they exist. Assisted-by: ClaudeCode:claude-opus-5
Implements #268 except its l10n/sweep tasks. Expiry was checked on access but never set by default and never swept, so a request that lapsed months ago still sat pending holding an unfillable placeholder. Expiry is now SET: the create dialog pre-fills 14 days ahead (confirmed with the product owner rather than defaulted here), clearable so a perpetual link stays one action away. `expires_at` has exactly one source -- that field -- so while it defaulted to empty almost nothing expired and a sweeper would have had nothing to act on. Formatted from LOCAL date parts, not toISOString(): datetime-local carries no timezone, so a UTC string would display a time the user never chose. Expiry is now ACTED ON: a TimedJob (hourly, following ExpireMachineLeasesJob) transitions lapsed pending requests to the new terminal STATUS_EXPIRED, invalidating the token and deleting an unfilled placeholder while preserving a re-request's Secret and values. Requests with NO expires_at are never touched -- the mapper predicate says `expires_at IS NOT NULL` explicitly rather than resting that promise on SQL three-valued logic. Attributed to the SYSTEM via forSystem(), because the requester took no action. VERIFIED LIVE: running the job swept three genuinely lapsed seeded requests, recorded request.expired with actor_type=system and no actor_id, and deleted the placeholder Secret. A CLAIM OF MINE THAT WAS TOO STRONG, NOW CORRECTED I wrote -- in the spec, in a code comment and in earlier commit messages -- that without hoisting the expiry check above the status switch, the sweeper's schedule would become part of the security boundary. That is false, and my own test proved it: with the check nested back inside the pending branch, testLapsedButUnsweptRequestIsRefused still passed, because that branch already checked expiry. A lapsed pending request was ALWAYS refused. What hoisting actually buys, now stated accurately in all three places: expiry takes precedence over every other status -- a locked request whose expiry passed reports "expired" rather than "temporarily unavailable", which is truer because locked invites a retry that can never succeed -- and a status added later cannot bypass expiry by omission. testExpiryTakesPrecedenceOverATemporaryStatus is the test that fails when the hoist is reverted; the others do not. The genuinely load-bearing half is the explicit `expired` switch arm: without it the new status falls to `default` and answers 500 "unknown state" to a recipient whose link simply ran out. testExpiredStatusReportsExpiryRatherThanAServerError asserts 410 and explicitly NOT 500. Also here: SecretRequestPolicy had NO direct tests, which is how a gate structured that way went unnoticed. It has 7 now. Task 3.3 needed no work -- the fill view already handled `expired`. Adjudicated rather than split: findLapsedPending pushes SecretRequestMapper to 11 public methods against phpmd's threshold of 10. A mapper's public surface is its query set, and splitting queries over one table across two classes is how a scoping rule gets applied in one and not the other. Verified: PHPUnit 1005/3212, vitest 581/581, phpcs 0 errors (lib), phpmd 0, psalm clean, php-cs-fixer clean, eslint 0 errors, l10n source check exit 0, openspec validate --strict passes. Two gates remain red and are addressed on the parent branch, not here: gate-46 (an @SPEC anchor I mistargeted in guards.js) and gate-19 (5 pre-existing secrets-write-ui scenarios pulled into scope by a header edit). Refs: #268 Assisted-by: ClaudeCode:claude-opus-5
The quality run on #270 named two real gaps. `src/router/guards.js` anchored its `@spec` at the change's delta spec, which disappears on archive. Retargeted at the requirement in the main spec, which is where a reader will still find it next month. `SecretRequestController` had no test file at all while carrying the mode selection between a fresh request, a re-request, a plain one and an application one. That absence is why the payload-shape defect survived: the dialog sent snake_case, the store forwarded camelCase, and the only test that touched creation mocked the store, so nothing asserted what the controller received. Six tests now pin each mode, including the explicit refusal of a re-request without a secretId. Four tests cover the SecretRequestService paths nothing exercised: the empty userId guard, and the three fail-soft catches. The rollback ones matter beyond coverage — they decide whether a failed request leaves an orphan placeholder Secret behind, and one of them asserts a failing rollback still surfaces the ORIGINAL error rather than the cleanup's. Assisted-by: ClaudeCode:claude-opus-5
Reviewer point on #270, and it is right: `deletePlaceholderIfUnfilled()` asked whether a Secret was ever filled by testing `key === ''` alone. A Secret can hold a login, a custom-field blob or a url with an empty key, and the delete it guards is hard — it takes the version history with it. Reachable through the UI with no race, because this PR is what made `key` optional at the checkbox level: ask for a login only, have it filled, open a second request on that Secret, revoke it, and the credential is gone. The same PR already carried the wider predicate for a different decision (`SecretService::hadNoValues()`), which is the tell. They stay separate functions: being too eager there writes a junk version row, being too eager here destroys a credential, so this one errs toward keeping the Secret and the residue is the orphan-placeholder tidiness the expiry cleanup handles. The reviewer's suggested remedy — a provenance flag recording that the Secret was created as a placeholder — is not needed. `create()` refuses an empty key unless `allowUnfilled` is set and `update()` refuses one outright, so a keyless user-owned Secret is already provably a placeholder. Filled-ness was the missing half of the test, not provenance, so no column and no migration. Three tests reproduce the loss (login-only, additionalFields-only, url-only) and failed against the old guard for the right reason: delete called once where it must never be called. A fourth pins that a genuinely empty placeholder is still cleaned up, so widening the test did not disable what it guards. The existing `testRevokeNeverDeletesAFilledSecret` missed all of this because it only ever filled `key`. Assisted-by: ClaudeCode:claude-opus-5
core.hooksPath points at .githooks, but the file was not executable, so the features.json regeneration it exists to do never ran on this machine. It is best-effort and never blocks a commit, which is exactly why nobody noticed. Assisted-by: ClaudeCode:claude-opus-5
.github#597 set cancel-in-progress on the shared quality.yml, but a
caller-level concurrency cancels the whole run before the called
workflows setting can apply -- so that fix reached only the apps that
declare no concurrency of their own.
Measured 2026-08-28 over push runs on development since #597 merged:
caller silent 0 of 11 cancelled
caller says true 7 of 13 cancelled (54%)
This repo is in the second group. pull_request keeps cancelling, where
superseding really is correct.
The skip-discipline gate now runs here (hydra-gates 1.10.0, #472) and reports one V3 finding — an exclusion with no reason recorded. The reason exists and is a good one; it sits in a block comment above the test, and `test.fixme(title, fn)` records no description in report.json. The body was a single line pointing AT that comment: // Intentionally empty — see block comment for the precise blocker. which tells a human where to look and a machine nothing. The reason moves onto the fixme itself, and the parenthetical leaves the title. Same test, still excluded, now attributable. Verified: npm ci rc=0, npm run lint rc=0 (0 errors), prettier clean, and `playwright test --list` compiles all 8 tests with the exclusion intact. Part of ConductionNL/.github#609. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
* refactor(manifest): the flow pages are an index and a flow `flows` and `flow-detail` are deprecated aliases. `flows` predates named index sources: a flow lives in OpenRegister's native flow table rather than a register/schema pair, so an object-backed index had nothing to bind to and the list needed a page type of its own. `config.entitySource` closes that, so the list is an ordinary `index` and only the EDITOR still needs its own type, now named `flow`. Behaviour is unchanged - both aliases resolve to the same components. The list page's `_note` is updated in the same commit: it asserted the old rationale (that an index "cannot address" a flow), which this change disproves. `config.app` is untouched and is load-bearing: the editor stamps it on a flow created there and the index filters on it. Requires @conduction/nextcloud-vue 2.21, where a named source's columns and create button are actually read. * chore(deps): bump @conduction/nextcloud-vue to ^2.21.0 The flow pages need 2.21.0: earlier releases DECLARE a named index source's columns, create button and row actions without reading them, so the migrated page renders a columnless table with no working create action. The lock is the part that matters. CI installs with `npm ci`, which honours package-lock.json and ignores how permissive the caret is — bumping the range alone would change nothing about what actually installs. --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Adds `keepiq-compose.yaml` and a setup page describing it. The compose brings up Postgres and Nextcloud, installs openregister (required), thematiq and integriq (optional) and keepiq from release tarballs, and enables them in dependency order. Nothing is bind-mounted: Nextcloud installs an app by deleting its directory and extracting an archive over it, so pointing that at a checkout deletes the working tree — measured on a development machine on 2026-08-27, where an app-store update fired on a container restart and removed every top-level file including .git. Release tarballs rather than a clone for a second reason: a tarball is a complete app carrying vendor/ and the built js/, and an app with no vendor/ does not fail loudly — it warns once and keeps loading, so it looks installed while every service needing a dependency is absent. The openregister dependency is not declared in appinfo/info.xml — no app in the fleet declares an <app> dependency — so the compose encodes what the manifest does not. Verified: docker compose config parses and interpolates; the same generated file was booted end to end for portaliq, which produced 17 registers, 86 schemas and 13 magic tables for its own register, with the portal content API returning a real site rather than an empty shell. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Two defects, both measured against a booted demo rather than inferred from
the code.
The verification step told you to run
curl -s -o /dev/null -w '%{http_code}' http://localhost:PORT/apps/APP/
and described a pass. That request is unauthenticated, and a Nextcloud app
page requires a login, so it prints 401 on a perfectly healthy demo. A reader
following the page would conclude the demo was broken. The command now sends
the demo credentials and says in words that a bare 401 is expected.
Thematiq additionally has no app route at all — it declares
<admin-section>theming</admin-section> — so /apps/thematiq/ answers 404 even
authenticated. Its page now opens Settings -> Administration -> Theming.
Verified on two booted demos: the corrected commands return 200.
Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…484) * feat(walkthrough): a first-visit tour built from this app's own menu ADR-043. The app had no walkthrough, so a new user arrived on a shell with no orientation at all. Every step targets a route READ FROM THIS MANIFEST, never invented. CnWalkthrough resolves target {kind: nav-item, ref} by querying [data-cn-route=<ref>], which CnAppNav emits from the ROUTE - so a tour pointing at a menu id that differs from its route still renders, as a centred anchorless coachmark that points at nothing. Verified: every ref resolves to a page this manifest declares. Where a menu label is a translation KEY rather than a sentence, the copy uses the key's last segment as a word. Printing launchpad.menu.dashboards at a user is worse than useless, and falling back to the route gives 'Click dashboard-detail in the menu' - an internal identifier read aloud. Verified: manifest validates against schema 2.26.0, every target ref resolves, and no raw translation key reaches the copy. * fix(walkthrough): point the tour at flows, and translate its copy gate-70 walkthrough-flows-stop - this app ships a flows page and no tour step targeted it. The gate's own measurement is why it exists: across 20 manifests, 12 apps shipped a flows page and exactly ONE tour mentioned it, so the automation surface was discoverable only by someone who already knew. Detected the way the gate detects it - type:'flows' OR the migrated form, an index over config.entitySource:'flows'. Matching on type alone would miss every app the flow-page migration touched, which is all of them. The step advances on route-match and sets allowManualNext. The same gate fails a step advancing only on object-created: that turns 'here is where flows live' into 'build an automation before you may continue', and a tour nobody can finish is worse than one that never mentions flows. gate-102 manifest-l10n-coverage - the tour's titles, bodies and tasks had no l10n/nl.json key. Added, and the browser catalogue rebuilt where the app ships one, because the browser reads nl.js rather than nl.json. * style(walkthrough): drop the em-dash from the flows step gate-96 (manifest-copy-style) fails this: voice.md section 8 is unambiguous — no em-dashes, Conduction strips them fleet-wide. The nl.json KEY is the English string, so it is renamed with it; leaving the key behind would orphan the Dutch translation of a string that still ships. The Dutch VALUE carried the same em-dash and gets the same treatment — the gate only counts manifest strings, but it is the same user-facing sentence. Note for a later sweep: this app's nl.json still holds 16 other em-dash strings from Vue copy. They are outside this gate's scope (it reads manifest strings) and outside this PR's. * fix(l10n): refresh the browser catalogue after the em-dash rename check:l10n-js reported 'Stale browser catalogue: l10n/nl.js'. Renaming the key in nl.json without nl.js leaves the two halves of the same catalogue disagreeing, and the runtime reads the .js one. --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
The walkthrough merge broke `vault-unlock.spec.ts`: a locked vault issued `GET /api/preferences/walkthrough_completed_version`, and that spec asserts — on the wire, not the DOM — that a locked vault requests NO Keepiq API endpoint. It is an allowlist that fails closed, precisely so a new request behind the lock screen is a deliberate act with a reason. The router guard could not have caught this, and says so: it works by refusing to resolve a route, so it only covers what mounts inside the `<router-view>`. CnAppRoot reads `manifest.walkthrough` and fetches the tour's completion preference when the SHELL mounts, before any route resolves — a shell-level sibling, which that guard's own note warns needs its own gating. So the manifest handed to the shell now omits `walkthrough` while locked. Withheld, not disabled: `isLocked` is read inside the render function, so the tour returns the moment the vault is unlocked and still runs on a user's first unlocked visit. Drawing a product tour over a locked vault would be wrong regardless of the request. Fails closed like the guard beside it — only an explicit `false` unlocks — so a store that failed to initialise withholds the tour rather than shipping it. Both properties are mutation-verified: reading the check as `!store?.isLocked` fails the fail-closed test, and removing the withholding fails the first. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…t a leak (#488) The first-visit tour added in #484 made CnAppRoot resolve `walkthrough_completed_version`, and that fetch runs before any route resolves — so it fires behind the lock screen and the vault-unlock invariant failed closed, exactly as designed. Judged rather than silenced. It is the same shell layer, the same GenericPreferencesController and the same `pref_`-namespaced session-user UI flag as the `support-dialog-seen` entry directly above it, and it carries no vault material. The a11y snapshot from the failing run shows the bare unlock form with no walkthrough card, so only the probe reaches the wire; a tour that painted over the lock screen would still fail this test on its own traffic. The `\b` anchor keeps the entry from widening onto neighbouring paths. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
… instead (#490) Two sessions fixed the same failure minutes apart. #486 withheld the walkthrough from the manifest while the vault is locked, so CnAppRoot never resolves the completion preference and the probe cannot reach the wire. #488 then allowlisted that probe — which the file's own note calls pure masking surface: an entry that cannot fire would silently swallow a real regression of exactly this shape. #486 is the better fix; the entry goes. What is NOT fixed by #486 is the order-dependence. The tour's seen state is per user, not per test, so whichever spec runs first wears it — every spec here unlocks, and the tour is offered on the first unlocked visit by design. audit-trail.spec.ts started needing a retry the moment #484 landed, having never retried in the three preceding development runs. Seeding the marker in global-setup, as dossiq already does, removes that. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
2.22.1 carries the theme app-id fix (nextcloud-vue#840). CnAppRoot calls useScopedTheme() with no slug, so this app resolved theme tokens, the token-set catalogue and the contrast check through a hardcoded 'nldesign' app id. thematiq is renaming to 'thematiq', and every path in that composable degrades to default styling by design — so once a renamed build is installed this app would render unthemed with nothing in any log. The LOCK is what moves here. A caret range alone changes nothing, because npm ci installs what package-lock.json pins. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Two independent faults, either of which alone stops the docs site updating. TRIGGER. This listened on a branch called `documentation`. Nobody has pushed to one since 2026-05-25, so every docs change merged to `development` passed review and published nothing. SECRETS. A reusable workflow receives no secrets by default. With none mapped, the callee's publish step finds CF_API_TOKEN empty and skips itself on its own guard, and the run finishes GREEN having changed nothing. Fixing only the trigger would have produced exactly that. The worker name is now pinned. Deriving it is the documented way to get a green run that reaches nobody: wrangler creates the derived worker and publishes there while the custom domains keep routing to the real one. Where the app was renamed, `canonical-host` turns the retired hostname from a second live copy of every page into a 301 to the same path on the current one. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
* feat(nav): a Flows surface in this app, on the shared page types ADR-110 Decision 4. A flow is app-specific — it operates on this app's objects — so the authoring surface belongs here rather than behind a deep link to another app's list. The ENGINE stays single (ADR-065): these pages are a scoped view onto OpenRegister's one native flow store, not a per-app store. Two manifest pages and one settings entry, no component files: `type: "flows"` and `type: "flow-detail"` are shipped page types in @conduction/nextcloud-vue 2.19.0, scoped by `config.app`. Note the layout of the diff: entries are appended textually rather than by reserialising the manifest. A `json.dump` round-trip rewrote pipelinq's file as a 3,950-line diff for a 20-line addition — correct output, unreviewable change. * build(deps): @conduction/nextcloud-vue 2.19.0 for the flows page types Required by the manifest change: `type: "flows"` / `type: "flow-detail"` are rejected by the compiled validator in earlier versions, and CI installs with `npm ci` — so the LOCK is what decides, not the `^2.x` range. Several of these locks were pinned many minors back, which is why some lockfile diffs are large: npm restructures the nested tree (mostly @esbuild platform binaries under @nextcloud/vue) to satisfy 2.19.0's peers. No direct dependency other than @conduction/nextcloud-vue changes. * test(router): classify the flow routes as vault-protected keepiq denies by default and asserts that every manifest page is explicitly classified, so adding two pages without classifying them fails — which is the test doing its job. Flows and FlowDetail are PROTECTED, not public. A flow in this app can read and write vault secrets, so the authoring surface must sit behind the lock exactly as the secret list does: a locked vault that still lets someone edit the automation over it is not locked. 656 unit tests green. * fix(icons): register Sitemap, or the Flows entry renders with no icon An icon name a manifest uses but src/icons.js does not register renders as NOTHING — not a fallback (ADR-077 rule 3). The Flows menu entry this PR adds uses `Sitemap`, and this app never registered it, so the entry would have shipped with an empty icon slot. Caught by gate-60 icon-vocabulary. I had checked `Sitemap` was registered in dossiq and carried the assumption to the fleet; each app keeps its own icons.js, and six of the twelve did not have it. The six failing gate runs were exactly those six apps. Both halves are required: the import alone is dead code, the registry entry alone does not resolve. * feat(flows): give the flow-detail canvas its sidebar The manifest _note claimed the controls rendered in the NC app sidebar, but the sidebarComponent field it described did not exist. Every #/flows/:id -- and #/flows/new, the same route with the literal id -- drew a bare canvas: savable and runnable, but with no way to name, describe, trigger or step-edit the flow, because those controls all live in CnFlowSidebar. Mirrors pipelinq#1490. ADR-110 Decision 4.
beta held 32 commit(s) development did not. Merged with -s ours: development's tree is kept BYTE FOR BYTE and only the ancestry is recorded. That is the payload -- without it the merge base never moves and the next development -> beta promotion conflicts on the version file exactly as before. 13 of 19 promotion PRs were CONFLICTING for this reason. Nothing is silently imported. What beta holds and development does not, and which this deliberately does NOT bring over: .forgejo/.beta-release-trigger .forgejo/issue_template/bug-report.yml .forgejo/issue_template/feature-request.yml .forgejo/issue_template/technical-task.yml .forgejo/issue_template/user-story.yml .forgejo/workflows/documentation.yml .forgejo/workflows/release-beta.yml .forgejo/workflows/release-stable.yml CODE_OF_CONDUCT.md CONTRIBUTING.md README.md SECURITY.md SUPPORT.md docs/scripts/validate-ai-baseline.mjs Those are dead Forgejo/Codeberg CI (removed from development on 2026-08-24/25 by 'chore(ci): remove dead Forgejo/Codeberg CI configuration'), generated Docusaurus build output, and community-health files that never existed on development. Each can be added deliberately if wanted; resurrecting them as a side effect of a sync is how a merge silently undoes a decision.
…260830084410 chore(sync): carry beta back into development
…0841 chore(sync): record beta's ancestry on development
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Dexie refuses to run twice in one page: it throws "Two different versions of Dexie loaded in the same app". Nextcloud loads openregister's global integration script and hermiq's agent leaf on every page, alongside whichever leaf app you are in, so all three have to agree on one dexie. After the dependabot sweep on 2026-08-30 they did not. openregister resolved 4.4.4 while hermiq resolved 4.4.5, and the throw happened before the leaf app mounted, so every app page rendered as bare Nextcloud chrome with no content. This pins the floor at ^4.4.5 and regenerates the lock, matching the apps that were already there. Verified in the browser: the Dexie error is gone from the console and app pages render their navigation and content again.
Release: merge development into beta
Dependabot proposed stylelint 17 on its own, which cannot work: it takes coordinated bumps, and the one that actually blocks it is easy to miss. - @nextcloud/stylelint-config -> ^3.2.2, the version that peers stylelint ^17.9.1 - stylelint -> ^17.9.1 - stylelint-config-recommended-scss -> ^17.0.1 and -recommended-vue -> ^1.6.1 where this app pins them - stylelint-webpack-plugin -> ^5.1.0 where this app uses it. This is the blocker: 5.0.1 peers stylelint only to ^16, and 5.1.0 is the first release that accepts ^17. The findings stylelint 17 then reports are fixed rather than silenced: `word-break: break-word` is deprecated and `overflow-wrap: break-word` is what it actually meant, the deprecated `clip` property becomes `clip-path`, and stylelint --fix's logical-property rewrites (`text-align: left` -> `start`) are correct for RTL. Verified: npm ci, npm run stylelint and npm run build all exit 0.
Release: merge development into beta
beta is the release candidate, 887 commits ahead of main. Every conflict resolved to beta's side, including its refactors: where beta had removed a file the removal stands rather than resurrecting a stale copy from main. Conflicts: 2 (0 took beta's content, 2 removed per beta's refactor).
git merged package.json and package-lock.json independently. Both merged without conflict, yet the PAIR came out inconsistent, so `npm ci` refused: npm error `npm ci` can only install packages when your package.json and package-lock.json are in sync. npm error Missing: node-fetch@3.3.2 from lock file On buildiq that failed the 'Install app npm dependencies' step, so the E2E suite never ran at all and reported as a red E2E check rather than as absent coverage. A manifest and its lockfile are one artefact and must be taken from the same side. beta wins here, as everywhere in this release.
Contributor
Quality Report — ConductionNL/keepiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ❌ | ||||
| stylelint | ❌ | ||||
| build | ❌ | ||||
| check-manifest | ❌ | ||||
| test-l10n | ❌ | ||||
| format | ❌ | ||||
| check-l10n-js | ❌ | ||||
| check-schema-l10n | ❌ | ||||
| composer | ✅ | ✅ 111/111 | |||
| npm | ❌ | ❌ | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-30 13:25 UTC
Download the full PDF report from the workflow artifacts.
Contributor
Quality Report — ConductionNL/keepiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 111/111 | |||
| npm | ✅ | ✅ 536/536 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-30 14:08 UTC
Download the full PDF report from the workflow artifacts.
Contributor
Author
|
Superseded. A newer beta-to-main promotion for this repo has merged, and |
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.
Stable release.
betawas 887 commits ahead ofmain.Conflicts were resolved to beta's side, including its refactors: where beta had removed a file, the removal stands rather than resurrecting a stale copy from main. No conflicts.
git merge -X theirssettles content conflicts but leaves modify/delete ones unmerged — beta deleted the file, so there is no "theirs" blob to take. Those were resolved by honouring the deletion.Verified before pushing: the commit has exactly two parents, and no conflicted path was left unresolved. Files main keeps that beta never had (archived openspec docs, whitespace-only differences) are preserved — "beta wins" governs conflicts, not additions.
A failing
… / releasecheck here is the App Store publish step, not a quality gate: 7 apps have no signing key and thematiq's certificate carries its old app id. The GitHub release and tag are still created.