Release: merge development into beta - #632
Open
github-actions[bot] wants to merge 67 commits into
Open
Conversation
A successful unlock swapped nothing on screen: the padlock stayed shut and the router replaced the whole view, so the only feedback was the vault appearing. The unlock's own success had no signal. The icon now swaps to LockOpenVariant, turns --color-success green and pops in over 400ms, and every unlock path -- online, the offline-snapshot fallback after a network error, and passkey -- holds $router.push for exactly that long. The hold is the point: the redirect unmounts this screen, so pushing first makes the swap unobservable no matter how the keyframes are written. 400ms is invisible next to the argon2 KDF that just ran, and `loading` clears only afterwards so the form cannot be submitted twice. A rejected unlock gets its own flash, and both outcomes are announced through a live region so the padlock is not the only channel carrying them. Under prefers-reduced-motion the pop is dropped (the colour stays -- that is state, not motion) and the wait is skipped, so that path unlocks faster rather than being delayed for an animation that never plays. The animation lives on the icon rather than its wrapper: the wrapper is a full-width block and scaling it would widen the card's content box while the animation runs. Component tests assert the ordering -- the open padlock is on screen while the push has not happened yet -- which is the part a refactor can reverse silently.
--default-clickable-area is 34px on NC 30 and NcAppNavigationItem sizes its icon column to it, so the 30px tinted disc filled the column almost edge to edge: the label sat 2px from a circled vault but 8px from the default vault's bare glyph. Two different gaps in one list, which is what made the coloured rows look misaligned rather than the default one. The column is now 8px wider and the disc 28px, restoring ~7px on both sides without moving the glyph's centre, and the default (colourless) vault gets the same circle in the theme's neutral surface -- so no row is the odd one out. On the active row that neutral disc becomes a low-alpha scrim in the row's own text colour: a grey disc on the solid-primary highlight reads as a stray pill, the same finding that sent the coloured discs to their opaque main-background variant. The colourless disc is a CSS class rather than an inline style because "no colour" has no hex to derive an active-row variant from. The icon-column rule mirrors NcAppNavigationItem's own three-class selector deliberately: that rule sets `flex: 0 0 var(--default-clickable-area)` at higher specificity than a short `:deep(.app-navigation-entry-icon)` form, so the obvious spelling loses the cascade and leaves the column at 34px with no error anywhere.
The live region added with the padlock animation announces "Vault unlocked. Opening your vault…", and check-l10n.js hard-fails on any t() literal that is not a key in en.json — so the frontend check went red on this branch the moment the string landed. The key ships here rather than in the pending bulk-translation branch because it is this feature's own string: a PR that adds an English literal owns getting it into all 36 locales, and the parity ratchet has no slack to absorb it later. Every locale keeps its existing missing count (the total moves 1125 -> 1126 on both sides), so nothing regressed. Only this one key is added. The bulk translation work stays on its own branch.
Adding the unlock and rejection timers to beforeUnmount made it a CHANGED method with no @SPEC line, which hydra gate-16 fails on. It had no docblock at all before, so the gate had nothing to read. The tag is an exclude: the hook clears timers and nothing else, and what each timer drives is specified on the method that arms it. The prose says which one carries risk -- the unlock timer's resolve drives $router.push, so leaving it armed would navigate after the screen is gone.
Bind-mounts the sibling ../thematiq checkout alongside keepiq and openregister, and enables it from the before-starting hook so every `docker compose up` brings all three apps up together. The header comment is updated to say "all mounted apps" rather than naming the two it used to enable.
RESTYLE-PLAN.md and screens/ are working notes and captured screenshots from the restyling pass — generated locally, never source.
Two problems are fixed together because they land in the same files.
First, the last 16 locale sets reach full parity. et, lv, lt and is had a
complete .json but a .js left behind at 582 keys, so their .js is
regenerated from the .json. The remaining 544 English source strings are
translated into Catalan, Albanian, Maltese, Irish, Luxembourgish and
Romansh for both the frontend (.js) and backend (.json) sets.
Second, nine locales carried a large block of the wrong language. When the
files were first seeded, ca was filled from es, lb from de, rm from it, sv
from da, and cs/sk/sl/sr/mk from hr; only part of that block was ever
translated. 3236 donor-identical values are replaced with real Catalan,
Luxembourgish, Romansh, Swedish, Czech, Slovak, Slovenian, Serbian and
Macedonian. sr and mk move from Croatian Latin to Cyrillic. Values that
were already correct are left untouched, so cross-language coincidences
(URL, PIN, Trezor, Kljuc) legitimately remain.
bs is deliberately unchanged: it shares 84% of its values with hr, but the
544 keys translated independently per locale are still 86% identical, so
that overlap is genuine Bosnian/Croatian similarity rather than seeding.
Every locale keeps the en.json key order and its plural-forms footer, and
all {placeholder} and %n tokens were verified to survive translation. The
.js files match `npm run l10n:build` byte for byte, so check:l10n-js is
clean. Outstanding translation debt goes from 8704 to 0 (keepiq#180).
ENFORCED now defaults to the resolved REQUIRED set instead of the literal 'nl', and tests/l10n/parity-ratchet.json is deleted rather than left holding stale slack. All 36 required locales are at zero missing keys, so the two-tier split the gate ran under while the debt was being paid off no longer buys anything — and the ratchet still tolerated 544 missing keys per locale, which would have let a new English source string regress 35 locales without turning CI red. Defaulting to REQUIRED rather than a hard-coded list of 36 means an override of L10N_REQUIRED_LOCALES carries enforcement with it instead of quietly leaving the newly-required locales unenforced. The ratchet machinery is kept for a locale that is ever added to REQUIRED incomplete; it is dormant while ENFORCED covers everything. Verified by deleting one sv key and blanking another: the gate reports both under [ENFORCED] and exits 1, where previously the ratchet absorbed it. README is updated in the same commit because its Translations section documented the two-tier behaviour and the ratchet file by name.
…260906124641 chore(sync): carry beta back into development
`quality / REUSE compliance` has been red on every run since the job was
added. It is non-blocking (`reuse-blocking` defaults to false in the shared
quality.yml), so it never failed a build — it only showed as REUSE ❌ in the
Quality Report comment, on every branch, including ones that changed nothing
about licensing.
`fsfe/reuse lint` on the tree before this commit:
Files with copyright information: 330 / 1466
Files with license information: 329 / 1466
Two causes. The 329 hand-written source files that DO carry
`SPDX-License-Identifier: EUPL-1.2` pointed at a licence text that was not in
the repo — there was no LICENSES/ directory, only a root LICENSE, which REUSE
does not read as the licence for the identifier. And the remaining ~1140
tracked files carry no header at all: the l10n JSON and its generated .js, the
openspec specs, docs, test fixtures, images and lockfiles.
Adding headers to those 1140 files is the other available fix and it is the
wrong one. An SPDX comment is not valid JSON, so every `l10n/*.json` would need
a `.license` sidecar; the same files are regenerated by
`scripts/build-l10n-js.js` and the lockfiles by the package managers. The REUSE
specification has a blanket declaration for exactly this, so this commit uses
it.
REUSE.toml declares one `path = "**"` annotation at `precedence = "closest"`,
which means a file's own header always wins over the blanket. Nothing about the
existing 329 headers changes, and `.editorconfig` — inherited from Nextcloud —
keeps its AGPL-3.0-or-later. "closest" also completes a half header rather than
rejecting it: `tests/e2e/visual/_visual-helpers.ts` declares a licence and no
copyright, `LICENSE` carries `EUPL © the European Union` and no SPDX tag, and
both now resolve.
LICENSES/EUPL-1.2.txt is byte-identical to the repo's own LICENSE.
LICENSES/AGPL-3.0-or-later.txt covers `.editorconfig` and is the copy already
used across the fleet.
After:
Missing licenses: 0 Unused licenses: 0 Bad licenses: 0
Files with copyright information: 1467 / 1467
Files with license information: 1467 / 1467
Compliant with version 3.3 of the REUSE Specification
The gate stays non-blocking. nextcloud-vue sets `reuse-blocking: true` because
it is a published package whose licensing is consumer-facing; keepiq is an app,
and the blanket is what keeps this correct without a gate. Every file added
from here inherits EUPL-1.2 and the Conduction copyright with nobody having to
remember anything, so the only way to regress is to vendor a file whose own
header names a licence with no text in LICENSES/ — and that shows up as REUSE ❌
in the Quality Report on the PR that does it.
LICENSES/EUPL-1.2.txt was copied from keepiq's own root LICENSE, and that file is not the canonical EUPL text. Every other app in the fleet — OpenRegister, OpenConnector, OpenCatalogi, docudesk — ships a root LICENSE that is byte-identical to the European Commission's text as published in spdx/license-list-data, and so does nextcloud-vue's LICENSES/EUPL-1.2.txt. keepiq's copy has had its typographic quotes stripped to ASCII at some point: `'the EUPL'` where the licence reads `‘the EUPL’`. Eighteen lines differ and the difference is nothing but those quote characters. That is tolerable in a root LICENSE, which people read. It is not what belongs in LICENSES/, which is the text a machine resolves `SPDX-License-Identifier: EUPL-1.2` against, and which should be the same bytes everywhere in the fleet. This replaces it with the canonical text, byte-identical to nextcloud-vue/LICENSES/EUPL-1.2.txt and to the SPDX license list (md5 41fa1e32337666d37d19786aebbf3923). LICENSES/AGPL-3.0-or-later.txt was already canonical and is untouched. `reuse lint` after the swap: 1468 / 1468 files with copyright and licensing information, 0 missing, 0 unused, compliant with version 3.3 of the REUSE Specification. The root LICENSE keeps its ASCII quotes for now; aligning it is a separate change.
Both rm files carried the Italian translation of "Vault unlocked. Opening
your vault…" byte for byte — the value was copied from it.json. The parity
ratchet cannot see that: the key is present and non-empty, so a
wrong-language value counts as translated.
It now reads "Cassaforta debloccada. Vossa cassaforta vegn averta…", using
the noun and verb forms the file's own genuine Romansh already uses
("Debloccescha l'emprim tia cassaforta e cuntinuescha lura.") rather than
introducing a second spelling.
The pre-existing Italian entries elsewhere in rm.json are untouched — that
is a separate cleanup for the bulk-translation branch.
Three signals on this screen did not do what their own docblocks said. Reduced motion dropped the announcement, not just the animation. playUnlockAnimation returned an already-resolved promise there, so `unlocked = true` and `$router.push` landed in the same task and the role="status" region was unmounted within a frame or two of getting its text — far below what a screen reader needs to speak it. Reduced motion is a preference blind screen-reader users commonly have on, so the one viewer who cannot see the open padlock was also the one who heard nothing about it. The hold now shrinks to UNLOCK_ANNOUNCE_MS (500 ms) instead of vanishing: still the faster of the two paths, no longer a silent one. Less motion, not less time. The rejection shake did not replay. flashUnlockRejected set the class false and then true again inside $nextTick — but $nextTick is a microtask, so both writes happened in one task with no style recalculation between them, and a CSS animation restarts only when the computed animation-name changes across one. The browser saw no change and replayed nothing, so a second rejection inside the 1100 ms window re-extended the red without shaking. The closed padlock now takes rejectionSeq as its vnode key: a fresh element animates unconditionally, which sidesteps the restart rule instead of fighting it. Setting the flag synchronously also keeps rejectedTimer inside beforeUnmount's reach — the $nextTick callback could arm it after teardown had already run. liveStatus's `checking` branch never announced anything. suiteCheck starts 'pending', so its text was in the region on the very first render — exactly the region-and-message-arrive-together case that is not announced — and then went empty when the check settled. All it did was put a second copy of the spinner's own visible "Checking your vault…" in the accessibility tree for a screen reader to read twice. Dropped; the visible label already carries it. The reduced-motion test now drives fake timers across the announce beat rather than asserting the push has already happened, and the repeat-rejection test asserts a NEW element rather than only a re-set class — jsdom runs no animations, so element identity is the only handle it has on the restart, and it is the right one: the key bump is what the fix turns on.
The colourless vault's active-row treatment assumed one highlight, and NC 34 has another. @nextcloud/vue gates two active-row rules on isLegacy34 (server major < 34) and both carry !important, so they beat the server's own solid-primary rule in core/css/apps.scss: - legacy (NC 32-33): the row is solid --color-primary-element with a whitened label, which is what the 20% white scrim was written for. - modern (NC 34+): the row is only a 16% primary tint over --color-main-background and the label stays --color-main-text. A 20% white scrim there is about 1.05:1 — an invisible disc, i.e. exactly the asymmetry this rule exists to remove. info.xml declares max-version="34", so that is the newest supported target. The scrim is now legacy-only and the modern row gets an opaque --color-main-background disc, which is the variant the COLOURED discs already take on that row, so the two read as one treatment rather than two. The icon-column colour rule gets the same split for the same reason: pinning it to --color-primary-element-text left the colourless glyph and the nested FolderOutline white-on-near-white (~1.28:1) on a modern row, where they should follow the label to --color-main-text. Both new rules are scoped under .keepiq-nav-tree like every other rule in the stylesheet; the old scrim rule was the one that was not.
`display: inline-block` sat on both .lock-screen__icon-open and .lock-screen__icon-rejected. Those are flashes that come and go, and `display` is a box property, so the padlock's box changed shape every time one cleared — which is how an "the icon jumped" bug gets built, even where the current numbers happen to work out. Measured in Chromium across font sizes, line heights and device scale factors, that inline/inline-block flip moves this glyph 0px: the svg is baseline-anchored either way, and at rest the 48px svg simply overflows its own 17px inline span. So this fixes no visible defect today. What it removes is the dependency — .lock-screen__glyph now owns the box for both padlocks and the state classes carry colour and animation only, so clearing a flash cannot move anything and the closed-to-open swap puts both glyphs in the same box.
…ault-glyph-spacing feat(lock-screen): unlock feedback on the padlock and the vault rail
Drops the ../thematiq bind mount and its `occ app:enable` from the before-starting hook. thematiq is a local-development concern that was never meant to land in this repo's compose file. It also broke the stack for everyone else. With no ../thematiq sibling checkout Docker bind-mounts a freshly created empty root-owned directory over custom_apps/thematiq, `occ app:enable thematiq` fails, `set -eu` aborts the hook, and the official Nextcloud image treats a failed before-starting hook as fatal — so the container never starts. openregister is a genuine dependency and stays; thematiq never was one. docker-compose.yml and docker/nextcloud/enable-apps.sh are restored byte-for-byte to their pre-thematiq state, header comments included.
The comment above ENFORCED said the two-tier split collapsed when "the last 8,704 keys landed" and that enforcement had started at `nl`. Neither survives a look at the merge base. There, parity-ratchet.json carried 72 entries totalling 38,804 missing/empty values — 544 per set for the 35 non-nl locales and 362 per set for nl — and ENFORCED was EMPTY, because no locale was complete enough to put in it. nl was the closest and still 362 keys short in each of its two sets. Left as it was, this comment would have become the repo's permanent record of when the debt cleared, understating it roughly fourfold and crediting a staged rollout that never ran. It also mattered for reviewing the branch: a reader of the old figure believes some twenty locales were untouched, when every one of the 36 in fact receives its missing keys here.
With ENFORCED defaulting to REQUIRED the ratchet is unreachable, not merely dormant. Every row in `failures` is drawn from REQUIRED, and the split sends each ENFORCED locale to hardEnforced before any bound is consulted, so the regressions and withinRatchet branches are dead. A locale added to REQUIRED is enforced along with it and hard-fails; it cannot fall through to a bound of its own. The only way back into the machinery is an explicit L10N_PARITY_ENFORCED naming a proper subset of REQUIRED, which is how a future bulk-translation push would stage itself again. That is a fine design, but roughly 115 lines and the closing advice described behaviour the default never exercises. Worse, the advice pointed at `--write` unconditionally: under the default that writes a parity-ratchet.json the next run does not read, the gate stays red, and whoever followed the line concludes the tool is broken rather than that the listed keys still need translating. So the comments now say what is true, the `--write` line is printed only when enforcement is actually narrowed, and `--write` itself exits 2 rather than leaving behind a dead ratchet file that a later run could mistake for sanctioned slack. README's description of the ratchet is corrected to match.
"Vault unlocked. Opening your vault…" was hand-inserted into en.json mid-file — not appended, so it did not come from `npm run test:l10n:write` — and it appears nowhere in src/, lib/ or templates/. Nothing was going to catch it either: check-l10n.js only asserts used -> present, never present -> used. It was not harmless bookkeeping. With ENFORCED defaulting to REQUIRED the key is permanently enforced across 36 locales x 2 files, so it would have had to be carried and kept non-empty forever for a message that is never displayed. The string belongs to the unlock-animation work, which already carries it in all 37 catalogues alongside the LockScreen call site that renders it, so it lands with the change that uses it rather than here. en.json is back to the 1125 keys it had at the merge base, which means this branch now adds no English source string at all.
Moving sr from Croatian Latin to Serbian Cyrillic rewrote only the values
that were still identical to hr, which left untouched the strings that were
already correct Serbian *Latin*. The catalogue came out at 1,070 Cyrillic
and 51 Latin: `Dashboard` rendered as `Kontrolna tabla` beside `Трезор`,
`Тајне` and `Извештаји`, with `Try again`, `Refresh`, `Admin settings`,
`Create vault`, `Filter by type` and `More actions` equally prominent. The
tour copy embedded Latin labels inside Cyrillic prose. Before the move sr
was at least uniformly Latin; after it, internally mixed.
Serbian is digraphic, so either script alone is fine — mixing them within
one catalogue is not. All 52 remaining Latin values are transliterated,
digraph-aware for lj/nj/dž, leaving only URL, BSN, CVV and
`Keepiq {version}`.
Four of them name a navigation item, and there a letter-for-letter
transliteration would have named something the menu does not show: the
Latin block said "Funkcije i plan razvoja" while sr's own label for
Features & roadmap is "Могућности и планови", and it left Flows in English
where sr calls it "Токови". Those four use the real labels instead.
uk was seeded from ru and only partly translated, and it was described as carrying Russian deliberately. Its own numbers say otherwise: 355 of the 581 seeded keys (61.1%) were still identical to ru, against 13 of the 545 keys added later (2.4%). That step change is the same signature that identified ca, lb, rm and the rest as contaminated — a uniformly high figure, as bs shows, is what a genuinely close language pair looks like. 121 values carried ы/э/ъ/ё, letters the Ukrainian alphabet does not have, 194 occurrences in total, and core UI was affected: Save read Сохранить, Cancel Отмена, Settings Настройки, Vault Хранилище, Secrets Секреты, Loading… Загрузка…. The keys this branch adds are proper Ukrainian, so the file was half correct and half donor text. 319 values are now Ukrainian, following the vocabulary the already-correct part of the file established: сховище, секрет, тека, спільний доступ, застосунок. The 49 that stay identical to ru are words the two languages genuinely share — Назад, Причина, Система, Пароль — plus acronyms and brand names, which is the residual overlap the cleaned locales also show (49 for ca, 68 for lb, 74 for sv). No value contains a non-Ukrainian letter any more.
be was seeded from ru exactly as uk was, and shows the same split: 349 of the 581 seeded keys (60.1%) still identical to ru, against 6 of the 545 keys added later (1.1%). 244 values carried и/щ/ъ — letters absent from the Belarusian alphabet — across 550 occurrences, so more than a fifth of the catalogue was demonstrably not Belarusian. 323 values are now Belarusian, following the vocabulary the clean part of the file established: сховішча, сакрэт, папка, супольны доступ, праграма. Two strings this branch itself added also said ссылка where be uses пасылка; both are corrected while the file is open. The 34 left identical to ru are genuinely shared words and acronyms, in line with the other cleaned locales, and no value contains a non-Belarusian letter any more.
The comment claimed `precedence = "closest"` completes the root LICENSE's half header (copyright, no SPDX tag). It does not: reuse-tool's _IGNORE_FILE_PATTERNS matches `^LICEN[CS]E([-\.].*)?$` before any REUSE.toml annotation is consulted, so LICENSE is never covered and was never counted as non-compliant. `reuse spdx` on this branch emits 1467 FileName: entries and ./LICENSE is not among them. The tests/e2e/visual/_visual-helpers.ts half of the example is correct and stays. Also drops the "of 1470 tracked files" denominator, which was neither the tracked count before this branch (1471) nor reuse's own (1466).
The License section promised EUPL-1.2 but said nothing about REUSE, so a contributor had no way to know that new files need no SPDX header, that their own header wins over the repo-wide blanket, or that vendoring a third-party file also needs that licence's text in LICENSES/. With the REUSE job non-blocking, this note is the control.
feat(l10n): complete all 36 locales and hard-enforce parity
The shared quality.yml defaults `reuse-blocking` to false because most fleet apps ship no REUSE.toml or LICENSES/ directory, and its failure hint says to flip it once an app ships both. This branch ships both and lints at 1467/1467, 0 missing, 0 unused, compliant with REUSE 3.3. The blanket `path = "**"` annotation makes every new file compliant on arrival, so blocking cannot be tripped by ordinary work. It leaves one regression mode: vendoring a file whose own SPDX header names a licence with no text in LICENSES/. CONTRIBUTING.md's REUSE note said the gate "reports" that case; it now fails the build, so the sentence says so.
The output literal inlined `publicPath: '/apps/<app>/js/'` from the base config
and then overwrote it 90 lines later with `publicPath: 'auto'`, so the inlined
value never took effect. That was reasonable while the base config was an opaque
package; now that both live in one file it just reads as a contradiction.
Set `publicPath: 'auto'` directly in the literal, carrying the explanation of
why the `/apps/` path 401s under `/custom_apps/`, and drop the override block.
Also drops three `|| {}` guards that defended against objects this file now
defines itself, and corrects the header's claim that only the listed fields were
inherited — `output.publicPath` was inherited and is deliberately not kept.
Behaviour is unchanged: requiring the config before and after under
NODE_ENV=production yields an identical resolved object.
The build row named `@nextcloud/webpack-vue-config`, which is no longer installed — a reader following it would look for a package that is not there. The frontend row said Vue 2.7 while package.json declares vue ^3.5.40 and the webpack config is written around Vue 3 / vue-loader@17.
…trees fix(deps): clear all npm audit findings by pruning the vulnerable subtrees
`locator('.lock-screen').getByText(/Wrong master password|decryption
failed/i)` matched two elements and failed under strict mode on a page
that was behaving correctly:
1) <p role="alert" class="lock-screen__sr-live">
2) <p class="input-field__helper-text-message">
Both are meant to be there. The helper text under the field is what a
sighted reader sees; the visually-hidden role="alert" is what a screen
reader announces, and it was added deliberately so a rejected credential
interrupts rather than going unspoken.
So both are asserted, rather than the match being narrowed to whichever
one is convenient. Narrowing would let the other be removed with no test
noticing, and for the live region that means losing the announcement
silently — the failure mode the region exists to prevent.
Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
hydra-gates v1.10.0 -> v1.16.0 nc-vue - -> 2.36.3 Lock-only: both packages are already declared with caret ranges that permit these versions, so nothing about what this app ACCEPTS changes - only what it currently resolves to. Opened by the weekly fleet shared-dependency bump, because a lock nobody re-resolves is a pin nobody chose. Merging is gated by this repository's own suite, deliberately: taking hydra-gates v1.8.1 added patchObject() to a published interface, which is a load-time fatal for any concrete double that implements it without the method. CI is the only thing that can tell a safe bump from that. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
#642 fixed this strict-mode violation in `tests/e2e/workflows/vault-unlock.spec.ts` and left the identical locator in `tests/e2e/spec-coverage/lock-screen.spec.ts` untouched, so keepiq's development E2E stayed red on the same defect: strict mode violation: locator('.lock-screen') .getByText(/Wrong master password|decryption failed/i) resolved to 2 elements: 1) <p role="alert" class="lock-screen__sr-live"> 2) <p class="input-field__helper-text-message"> I reported keepiq as fixed after #642 merged. It was not: I had fixed the spec that failed rather than the LOCATOR that was wrong, and the second copy failed the very next run on a tree containing the fix. Same treatment as #642, so the two files now agree. Each surface is asserted on its own: the helper text is what a sighted reader sees, the visually-hidden role="alert" is what a screen reader announces, and it was added deliberately so a rejected credential interrupts rather than going unspoken. Narrowing to either one alone would let the other be removed with no test noticing, which for the live region means losing the announcement silently. The two remaining uses in `tests/e2e/workflows/_workflow-helpers.ts` are `.count()` and `.first().textContent()`. Neither is strict-mode sensitive, so they are correct as they stand and are left alone. prettier clean. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Roughly half of the config literal in webpack.config.js was inlined verbatim from @nextcloud/webpack-vue-config (AGPL-3.0-or-later, © Nextcloud GmbH) when that package was dropped to clear an elliptic advisory with no patched release. The file carried no header of its own, so REUSE.toml's repo-wide blanket declared it Conduction B.V. / EUPL-1.2 — a licence and a copyright that are not ours to assert over that code. Give the file a dual header. `precedence = "closest"` means it wins over the blanket, the same arrangement .editorconfig already uses for its own Nextcloud origin. LICENSES/AGPL-3.0-or-later.txt is already present. REUSE.toml's comment enumerated .editorconfig as the only file whose own header wins; it now names both and says why this one differs. No behaviour change: the resolved webpack config is byte-identical.
…header Review of #647 found that the copyright glyph in the header's prose is read by REUSE as a copyright statement anywhere in a file, not only inside an SPDX tag, so it reported a third holder whose name was the remainder of the sentence. Drop the glyph — the tag on line 1 already carries the attribution — and say so in the header without naming any of the three marker forms literally. Also from that review: - The header claimed the rest of keepiq is EUPL-1.2, which .editorconfig contradicts eight lines later. - "see the WHY block further down" pointed at an unlabelled block, and the pre-existing "(see the header)" back-reference at the node-core fallbacks had become circular. Give that block a WHY THE BASE CONFIG IS INLINED heading so both references resolve. - "several of upstream's own explanatory comments" is two, measured. - REUSE.toml said both files "stay" AGPL; only .editorconfig was already so. - The copy-out warning now says why it is absolute: Conduction may relicense its own half, but nothing marks which lines are which. Comments only. The resolved webpack config is byte-identical.
…onfig chore(license): declare webpack.config.js AGPL-3.0-or-later
…n the test bootstrap (#650) On 2026-09-08 a PHPUnit run in openregister took 19 GB of RAM because the test bootstrap loaded lib/base.php from a source tree that was never installed and the CLI php.ini has memory_limit=-1. This is the fleet-wide follow-up. - phpunit.xml and phpunit-unit.xml: hard memory_limit=2G with the measured peak (75 MB for 1158 tests without coverage). - tests/bootstrap-unit.php (booted by both configs): the inline dbtype/dbhost check becomes keepiq_nc_root_is_installed(), which also requires installed => true and reads $CONFIG in a closure instead of leaking it into the global scope. A bare source tree prints one STDERR line and the run continues in pure-unit mode; if base.php is loaded and throws, the run prints the reason and exit(1)s instead of continuing half-booted. - tests/bootstrap.php (unreferenced by either config, but it loads base.php unguarded and called OC_App::loadApps() unconditionally): same helper, same fail-loud behaviour, OC_* calls guarded with class_exists. - composer.json: psalm gets --memory-limit=2G, all three phpmd invocations run under php -d memory_limit=2G. phpstan already had --memory-limit=1G. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…grations into one The app id moved doriath -> keepiq in 2026-08, but the 33 physical tables and their 78 indexes kept the old prefix. This moves them, ahead of the App Store launch, so the app ships under one name throughout. Version001000Date20260908000000 replaces all 35 incremental migrations. It renames each table in place in preSchemaChange, so existing installs keep every row, then declares the full target schema. On PostgreSQL it also renames the sequences and primary-key constraints, which a table rename leaves behind and which the schema comparison does not reconcile. Indexes are left to that comparison, which drops the undeclared old names and creates the new ones on every supported platform. The declared schema was generated from `occ db:schema:expected --sql`, which replays the old chain, and then diffed field by field against it: 33 tables, 309 columns, 78 indexes, zero differences. The rename itself was rehearsed against a copy of the live database: all 33 tables moved, per-table row counts identical, both sequences and all 33 primary keys followed, and an insert through a renamed sequence still issues ids. Also rewrites the info.xml comment that argued the prefix should stay frozen, and corrects a claim in MigrateUserPreferences that the rename does not touch keepiq_enc_suites. The <database> entries stay load-bearing: ten tables are still 23 characters or longer, which is the default-primary-key-name limit ensureOracleConstraints applies when no <database> is declared.
phpmd-db.xml and Db/HoneyAlert.php both justified a rule exception by naming the migration that owns the column in question. All four of those migrations are deleted on this branch, so each justification pointed at a file that no longer exists. Both now name Version001000Date20260908000000, and both claims were re-checked against the schema it declares rather than carried over on trust: keepiq_secrets has 19 columns, keepiq_siem_sinks has 18, and keepiq_honey_alerts still carries `ip`. Those are the three facts the two comments rest on.
Eight tests, 217 assertions, over the code this change adds. The coverage
ratchet caught the same gap on the sibling integriq PR and it applies here too:
a migration that replaces 35 files is a lot of new statements, and none of them
had a test.
What they pin down:
- a fresh install gets all 33 tables, 309 columns, 78 indexes and a primary
key on every table, counted from the declaration rather than hardcoded
- a table that already carries everything gains nothing, which is the path an
install takes on the upgrade after this one
- every table still on the old prefix is renamed exactly once, and an install
already renamed is a no-op
- MySQL gets RENAME TABLE and the others get ALTER TABLE, because MySQL is the
one platform that does not take the second form
- PostgreSQL also moves the sequence and the primary-key constraint, which a
table rename leaves behind and the schema comparison does not reconcile
- a name that is not a bare identifier is refused
The last test compares the 33 declared tables against the 33 tableName literals
in lib/Db. Those are two independently maintained lists, and a mapper left on
the old name would query a table that does not exist: a runtime error rather
than a failing build.
It was mutation-checked rather than trusted. Pointing SecretMapper at
keepiq_secretz turns it red, and restoring the name turns it green again.
The Doctrine table is a local double. Doctrine is only on the autoloader when
this app sits inside a server checkout, so a test building a real Table would
pass in CI and fail on a standalone clone. ISchemaWrapper::getTable() declares
no return type, which is what makes the double possible.
Answers "which of these users can I share a secret with" in one request. A
share dialog offering a list of candidates previously had to call
share#recipientCertificate once per candidate; nothing listed shareable users,
and GET /api/v1/suites is scoped to the caller's own suites.
POST /api/v1/shares/recipient-certificates { "userIds": ["alice","bob"] }
-> { "recipients": [
{"userId":"alice","shareable":true,"certificate":"<PEM>"},
{"userId":"bob","shareable":false,"reason":"no_active_suite"}
]}
IT PROBES, IT DOES NOT ENUMERATE. The caller supplies the ids - in practice
from Nextcloud's own sharee search, which is already permission-filtered - and
learns nothing about a user it did not name. There is deliberately still no
endpoint listing the users who hold a suite: certificates are public keys and
safe to distribute, but "who has a keepiq vault" is a membership disclosure
gated by no sharing permission, and a list endpoint would hand it to every
authenticated account.
An unknown user and a user without an active suite are reported identically, so
the endpoint cannot serve as a user-existence oracle. The single-recipient
endpoint already collapses both into one 404, so nothing is lost.
The lookup is one IN query rather than N, and EncryptionSuiteMapper::
findActiveByOwners() reproduces findActiveByOwner()'s ORDERING, not just its
filter. Compromise recovery leaves the old suite `active` until the migration
terminates, so an owner can legitimately have two active rows; a plain
`owner_id IN (...) AND status = 'active'` returns both and lets the database
pick - in practice the oldest, the certificate the owner is migrating AWAY
from. Encrypting to it yields a copy the recipient cannot open. Same sort here,
first row per owner wins, and a test asserts the batch and single lookups
cannot drift apart.
Input is deduplicated, order-preserving so the caller can zip the response
against what it sent, and capped at 100 ids - a bound rather than truncation,
since a silently shortened answer looks like a set of non-shareable users.
Specifies the behaviour as "Recipient Shareability Lookup" in
openspec/specs/user-sharing/spec.md, which had described encrypting to a
recipient's certificate but never how a client discovers whether one exists.
Assisted-by: ClaudeCode:claude-opus-5
…olidate-migrations Rename the doriath_ tables to keepiq_ and consolidate 35 migrations into one
Repoints AttachmentService::BLOB_APP_DATA_NAMESPACE at Application::APP_ID and
relocates the blobs to match, in one change - because doing either alone loses
every attachment, and loses it silently.
`IAppDataFactory::get($namespace)` resolves to
`appdata_<instanceid>/<namespace>/`, so the constant is a storage location, not
a label. Repointing it without moving the files opens an empty
`appdata_<instanceid>/keepiq/`: uploads keep working, every existing download
404s, and nothing logs an error - the `keepiq_attachments` rows still name a
`blob_ref` that exists, just in a folder nothing looks in any more. The bytes
are AES-GCM ciphertext whose file key is RSA-wrapped per recipient, so an
orphaned blob cannot be regenerated from anything the server holds.
MoveAttachmentBlobs runs pre-migration, alongside RenameDoriathTables:
- It moves through ISimpleFolder, not the filesystem. A `mv` on disk would
strand the `oc_filecache` rows, which is the same silent-404 one layer
down.
- It verifies before it deletes. The source is removed only once the target
exists and reports the same byte count.
- It refuses rather than overwrites where the target already holds a blob of
that name, since choosing between two ciphertexts is not a repair step's
decision.
- It streams via ISimpleFile::read() so a large attachment never lands in
memory whole, with a string fallback for backends that return no handle.
- It never throws; an escaping pre-migration exception aborts the upgrade.
The empty `appdata_<instanceid>/doriath/` directory is left behind and
reported: ISimpleRoot exposes no delete(), and removing it on disk would
strand its filecache row. An empty directory and one cache row are harmless.
Verified end-to-end on an instance holding a real blob: moved 1 file in 0.13s,
sha256 identical across the move (9a210e56...), filecache repointed from
fileid 592/593 to 595/596 with matching sizes, second run a clean no-op.
Assisted-by: ClaudeCode:claude-opus-5
The relocation shipped without a spec. The one-off doriath -> keepiq move does not need one, but the invariant behind it does, and it is the invariant that made the move dangerous in the first place: a blob's address has two halves in two different stores — the AppData namespace naming the folder and `blob_ref` naming the file — and changing one without the other fails silently. Uploads keep working, every existing download 404s, nothing is logged. Adds "Blob addressing survives relocation" to encrypted-attachments, covering that consistency rule, the requirement to relocate through the storage API so the file cache moves with the files, and the verify-before-delete / refuse-rather-than-overwrite / never-abort-the-upgrade properties the step already implements. MoveAttachmentBlobs' `@spec exclude` tags claimed no canonical spec described the move. That is no longer true, so they now reference the requirement. Assisted-by: ClaudeCode:claude-opus-5
…ipient-certificates
… suite (#657) The bootstrap guard added earlier today refuses to load lib/base.php unless config/config.php declares installed => true, and that part stays. It also made the bootstrap exit(1) when the tree IS installed and base.php throws part-way, which was the wrong trade: on humaniq that turned all six PHPUnit legs red on a suite that passes, because base.php dies on a Doctrine constant the app vendor and the server disagree about. Reaching a half-built container needs an autowiring lookup, this app has none in lib, and phpunit.xml caps a run at 2G anyway. So the catch now says plainly that the container is unreliable and lets the pure unit tests run. The not installed branch still refuses, unchanged. Follows humaniq PR 392. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…-update chore: updated nextcloud-vue package
CI caught a real defect, not a lint nit. Hydra gate-110 (migration-version-bump):
1 migration(s)/repair step(s) added without moving <version> in
appinfo/info.xml, so `occ upgrade` will answer "No upgrade required."
and run none of them
MoveAttachmentBlobs is a pre-migration repair step, and pre-migration steps
only run when Installer sees a version change. Without the bump the step would
never execute on a real install, AttachmentService would still be repointed at
the empty keepiq namespace, and every existing attachment would 404 silently -
precisely the failure this PR exists to prevent. 0.3.0 -> 0.3.1.
Also fixes two phpstan findings in the same file:
- the `catch (NotFoundException | Throwable)` in sourceFolder() listed a
class Throwable already subsumes, so the first arm was dead.
- the `is_resource()` guard before fclose() in copy() was reported as always
true, and it is: Nextcloud's file_put_contents copies FROM a passed
resource and closes only its own target, never the source, so the handle is
still open in the finally on both the returning and the throwing path. The
guard was defending against a close that does not happen.
Assisted-by: ClaudeCode:claude-opus-5
Answers two phpmd findings by removing a duplication rather than raising a threshold. ShareService carried both recipientCertificate() and recipientCertificates(), which pushed it to 11 public methods. The singular was a thin delegation used by exactly one caller, and the batch subsumes it, so it is gone: the single-recipient endpoint now calls the batch lookup with a one-element list and reads the entry back. Both endpoints resolve a recipient by exactly one code path and cannot drift apart - which matters here, because that path carries the newest-active-suite ordering that compromise recovery depends on. ShareController::recipientCertificates() sat exactly on the cyclomatic complexity threshold; the id normalisation is now its own method, which is where the branching lived. Behaviour is unchanged. The single-endpoint tests were repointed at the batch mock and still assert the same 200 and 404. Assisted-by: ClaudeCode:claude-opus-5
Found by gate-113 (exclusion-evidence, ConductionNL/.github#708). "An admin revokes a pending secret request" cites four methods on SecretRequestServiceTest. That class exists and holds none of them: its methods are testCreate*, testApprove*, testDecline* and testGetByToken*. All four live, verbatim, on ApplicationRequestAdminServiceTest: testAdminRevokeDeletesTheUnfilledApplicationPlaceholder testAdminRevokeNeverDeletesAFilledApplicationSecret testAdminRevokeWillNotDeleteAnotherApplicationsSecret testRevokeForApplicationRefusesARequestOfAnotherActor So only the class name was wrong. The coverage was always there, and nothing could confirm it because the citation pointed at a class that does not assert this behaviour. One word changed; the four method names are untouched.⚠️ VERIFIED BY INSPECTION, NOT BY RUNNING. This checkout has no vendor/, so phpunit cannot run here. Each of the four is confirmed present as `function <name>` in the class, and `php -l` is clean. CI runs them. gate-113: FAIL (1 unresolved) -> PASS. Spec text only.
Hydra gate-113 (exclusion-evidence) fails on every PR against development: openspec/specs/application-mgmt/spec.md:158 @e2e exclude -> SecretRequestServiceTest::testAdminRevokeDeletesTheUnfilled ApplicationPlaceholder not found Pre-existing and unrelated to this branch, but it blocks the gate, and the correction is unambiguous. All four cited methods live in ApplicationRequestAdminServiceTest. SecretRequestServiceTest still exists, which is why the citation reads as plausible; it just holds none of them. Assisted-by: ClaudeCode:claude-opus-5
…tion Two Hydra gate failures. gate-16 (spec-coverage): recipientCertificate()'s body changed in this branch - it now resolves through the batch lookup - which makes it a changed method, and it carried no @SPEC. It has one now: the Recipient Shareability Lookup requirement covers both endpoints, which is the point of routing them through one code path. gate-113 (exclusion-evidence): openspec/specs/application-mgmt/spec.md:158 cites SecretRequestServiceTest for four methods that live in ApplicationRequestAdminServiceTest. Pre-existing, unrelated to this branch, and failing on every PR against development; the correction is unambiguous, since all four are in that one class. SecretRequestServiceTest still exists, which is why the citation reads as plausible - it just holds none of them. Assisted-by: ClaudeCode:claude-opus-5
… by id Three review points from #656. DEDUPLICATION IS NO LONGER A LOOP (Barry). The hand-rolled walk called in_array() against a growing array, so collecting n distinct ids cost O(n^2) and a caller could buy that work before the request was refused. array_unique does the same job - first occurrence wins, original order kept - without the rescan. Not a keyed set, despite that being the obvious fix: PHP coerces a numeric-string array key to int, and Nextcloud user ids may be numeric strings, so "0123" would come back as 123. THE BOUND STAYS ON DISTINCT RECIPIENTS. With the scan gone, the reason to check before deduplicating goes with it, and the limit reads as what it actually is: a cap on how many people one lookup may cover, not a defensive guard against payload size. A list naming the same person a hundred times is asking about one person and is answered, not refused. RESULTS ARE CORRELATED BY ID, NOT POSITION (Wilco). The docblock promised callers could zip the response against the request, which duplicate and malformed entries break: ["alice","alice","","bob"] answers about two people. Every entry already carries its userId; that is now the documented contract, with first-seen order kept as a convenience rather than a guarantee. THE MAPPER TEST NO LONGER SWALLOWS REGRESSIONS (Wilco). Its setUp ran a query and skipped on any Throwable, so a syntax error, a binding mistake or a driver incompatibility in findActiveByOwners() would have reported as "migrations have not run" and turned a red build green. It now tests the missing-schema condition directly with tableExists() and catches nothing else - plus a test asserting that guard names the table the mapper actually reads, so a future rename fails the suite instead of silently skipping it. 1185 tests pass; phpmd, phpstan and phpcs clean. Assisted-by: ClaudeCode:claude-opus-5
Review point from #654, and a real gap between two halves of this PR. MoveAttachmentBlobs is deliberately non-fatal: a pre-migration step that throws aborts the upgrade and leaves the app half-migrated, so every failure path leaves the source blob where it is and carries on. But AttachmentService was repointed at the new namespace in the same change, so a blob the step declined to move - a collision, a short copy, a storage error - kept its bytes in a place nothing looked. Bytes intact and unreachable is indistinguishable from lost, which means "we preserved the source" was protecting the data without protecting the attachment. Reads and deletes now resolve the new namespace first and fall back to the old one, so availability does not depend on the relocation having completed. That is what makes a non-fatal migration honest rather than merely quiet. Writes still go only to the new namespace, and the fallback is a read-only tail that retires with the rest of the pre-stable compatibility. The collision case had the same shape and is also fixed. Refusing to overwrite protected the destination but said nothing about whether it held the SAME blob, while reads were being switched onto it regardless. Sizes are now compared: equal means the relocation already ran and the source is redundant, so it goes and a resumed migration converges. Different means two distinct blobs share a ref - both are left alone and reported, because choosing between two ciphertexts is a decision about data. Covered by a download-through-the-fallback test and both collision branches. 1180 tests pass; phpmd, phpstan and phpcs clean. Assisted-by: ClaudeCode:claude-opus-5
…icates feat(sharing): batch recipient-certificate lookup for share dialogs
…obs-v2 refactor(storage): move attachment blobs to the keepiq AppData namespace
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.
Automated PR to sync development changes to beta for beta release.
Merging this PR will trigger the beta release workflow.