Release: merge beta into main - #512
Merged
Merged
Conversation
…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
…never had" This reverts commit af14355 on this branch only; the header is correct and will land with the change it announces. The header names `owner-editable-additional-fields` (#272), which is not implemented on this branch. It only got committed here because that is the branch I happened to be standing on when I wrote the proposal. #270 is the request-first secret-request flow; a spec header for a different change is not its concern. It also had a consequence. gate-19 is diff-scoped, so touching openspec/specs/secrets-write-ui/spec.md pulled its scenarios into this PR's scope and surfaced five with no e2e test at all: create-a-secret-inside-the-current-folder, edit-metadata-only, modal-opened-via-registry-dispatcher, name-and-value-are-required, revoke-a-link-share. That gap is real and pre-existing — not something this PR introduced and not something to paper over with an `@e2e exclude`, which is for behaviour proven elsewhere, not for behaviour nobody proved. It belongs to #272, which modifies that spec for real. Moving the header there moves the obligation with it. Assisted-by: ClaudeCode:claude-opus-5
#268 builds on the request-first flow, so it takes #267's review fixes with it. The test file conflicted because both branches appended methods at the end of the class. Resolved by rebuilding from both stages member-by-member rather than by hand: git's boundary fell mid-method, so keeping "both sides" of the marker spliced two half-methods together and would not parse. Worth noting what the merge carries: expire() calls the same deletePlaceholderIfUnfilled() that the revoke path does, so the widened filled-ness test arrives here too. Without it the expiry job — unattended, 500 requests a batch — could have hard-deleted secrets holding a login or a custom field with no key set. Assisted-by: ClaudeCode:claude-opus-5
expire() and decline() share deletePlaceholderIfUnfilled(), so the revoke-side tests already cover this. Pinned separately because the expiry caller is the dangerous one: the job runs unattended, 500 requests to a batch, with nobody reading the outcome. If someone later gives expiry its own emptiness test, this fails instead of the loss surfacing as a user's missing credential. Proven to discriminate: narrowing holdsNoValues() back to `key === ''` fails this test and the three revoke ones; restoring it passes all four. Assisted-by: ClaudeCode:claude-opus-5
5.1 asked for translations of every new UI string. There are none: #268's only src change is the expiry pre-fill in SecretRequestCreateDialog.vue, which reuses labels #267 already shipped and translated. Both l10n checkers exit 0 and no locale regressed — recorded as satisfied rather than skipped, because "nothing to translate" is a finding, not an omission. 5.2 sweep, all on this branch: hydra gates 49/49 applicable PASS (gate-4 composer-audit cannot run here) PHPUnit 1020 tests, 3237 assertions, 1 skipped vitest 581 tests across 80 files phpcs lib/ 0 errors php-cs-fixer 3 files fixed (comment indent, import order), now clean openspec 50/52 — see below @SPEC anchors all five new methods anchored; gate-46 resolves each one The two openspec failures are one pre-existing cause: a requirement in encryption-suites lacks SHALL/MUST, added by restore-suite-migration-loop, the change still waiting to be archived. Neither file is touched by #267 or #268, so it is not this PR's to fix — noted for the archive of that change rather than folded in here. Assisted-by: ClaudeCode:claude-opus-5
Replaces the per-channel release callers with a single release.yml that calls ConductionNL/.github/.github/workflows/release.yml@main for all three channels. Removed: release-beta.yml release-development.yml release-stable.yml
…rtw) (#276) `Security (composer)` went red on development today with Found 1 security vulnerability advisory affecting 1 package: Advisory ID: PKSA-32g2-byr9-drtw Title: Incorrect Ed25519 public key validation Affected versions: >=2,<2.5.1|<1.24.1 Reported at: 2026-08-18 Nothing in this repository changed. `composer audit` reads a LIVE feed, so a newly published advisory flips the verdict on an unchanged tree — the same commit that was green this morning is red this evening. This one was published today. sodium_compat is transitive, so the lock is the only file that moves. `composer audit` is clean afterwards, verified locally. openregister carries the same v2.5.0 and needs the same bump; any other app whose lock contains sodium_compat below 2.5.1 will show this too.
Bumps [phpseclib/phpseclib](https://github.com/phpseclib/phpseclib) from 3.0.55 to 3.0.56. - [Release notes](https://github.com/phpseclib/phpseclib/releases) - [Changelog](https://github.com/phpseclib/phpseclib/blob/master/CHANGELOG.md) - [Commits](phpseclib/phpseclib@3.0.55...3.0.56) --- updated-dependencies: - dependency-name: phpseclib/phpseclib dependency-version: 3.0.56 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com>
…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
…260830125038 chore(sync): carry beta back into development
Release: merge development into beta
…jects (#519) keepiq was held back from the fleet's vitest 4 adoption because it is the one app using `environmentMatchGlobs`, which vitest removed in 4. Without it every DOM spec loses jsdom and dies with `ReferenceError: window is not defined`. The split it expressed is real and worth keeping: the crypto specs use WebCrypto from `globalThis.crypto` and are faster on node, while the component specs mount SFCs and need a document. So it is now two projects, both `extends: true` so they inherit the timeouts, setupFiles, stubs and inlined deps configured once at the root. Only name, environment and include differ, which is the whole of what environmentMatchGlobs did. One spec also had to be fixed rather than carried. `tests/store/export.spec.js` installs spies on SHARED prototypes (HTMLAnchorElement.prototype.click, Storage.prototype.setItem) in `beforeEach` and never restored them, so each test stacked another wrapper on the same method and calls accumulated. That is a latent leak, not a vitest 4 regression: it made `expect(clickSpy).not.toHaveBeenCalled()` observe two clicks the test never made. An `afterEach` restore fixes it at the source. Verified: 92 test files and 669 tests pass at exit 0, npm ci and npm run build both exit 0.
Release: merge development into beta
…260830153126 chore(sync): carry beta back into development
Release: merge development into beta
main held 12 commit(s) beta did not. Merged with -s ours: beta's tree is kept BYTE FOR BYTE and only the ancestry is recorded, so the beta -> main promotion stops conflicting on files where beta is simply newer. Not brought over -- beta is hundreds of commits ahead of main, so these are the OLDER copies, and several are dead Forgejo/Codeberg CI that development deliberately removed: .forgejo/workflows/documentation.yml .forgejo/workflows/release-stable.yml .github/workflows/branch-protection.yml .github/workflows/code-quality.yml .github/workflows/documentation.yml .github/workflows/issue-triage.yml .github/workflows/openspec-sync.yml .github/workflows/release-beta.yml .github/workflows/release-stable.yml .github/workflows/sync-to-beta.yml appinfo/info.xml package-lock.json package.json
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 17:22 UTC
Download the full PDF report from the workflow artifacts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stable release:
betaholds 887 commit(s)maindoes not.Merged with
--merge, never--squash. Squashing a promotion rewrites the carried commits into onebetadoes not contain, so the branches diverge again immediately and main's own commits read as reverted.A failing
… / releasecheck on this pull request is the App Store publish step, not a quality gate. Eight fleet apps cannot publish today: seven have no signing key, and thematiq's certificate carries its old app id (Nextcloud issues one certificate per id, CN = the id). The GitHub release and tag are still created. Every other check must be green for this to merge.