release: first stable release of Portaliq - #263
Merged
Merged
Conversation
beta.194 fixes stat/KPI cards rendering as bare tiles (the same fix openbuild needed). Resolved with --min-release-age=0 per the .npmrc note for fresh first-party releases; npm ci installs from the lockfile unaffected.
development landed the beta.190 bump while this branch was open, so both sides touched the nc-vue pin. Resolved by taking development's dependency state and re-applying beta.194 on top (194 fixes stat/KPI cards rendering as bare tiles).
…as bare tiles)' (#40) from chore/ncvue-beta190 into development
…ovider deleted, 1 of 4 schemas exposed' (#41) from wip/mcp-adoption into development
Picks up the live-updates release: liveUpdatesPlugin is now default-on in
createObjectStore (lazy until the first subscribe) plus a first-subscription
transport fix.
Version bump only — portaliq's data pages (Portal accounts / sessions /
messages / documents) are manifest-v2 index/detail pages rendered by the
shared CnPageRenderer, which fetches through the package's shared
useObjectStore instance. That shared instance is created without plugins,
so it exposes no subscribe(); manifest JSON cannot carry a store instance
into CnDetailPage's objectStore prop either. Live-updates adoption for
manifest-rendered pages therefore has to land inside the nc-vue renderer,
not in this app. The app's own createObjectStore scaffold ('example')
gains the plugin automatically with this bump.
…) — manifest pages need renderer-side support' (#42) from feat/ncvue-beta212-live-updates into development
…renderer' (#44) from chore/ncvue-beta213-lockstep into development
…ype schemas Supertype schemas (e.g. pipelinq's ticket schema, whose ticketType discriminator separates requests / complaints / contact moments) need per-collection narrowing so three collections over one schema don't each return all kinds. Adds: - PortalObjectReader: optional $filter narrowing applied BEFORE the scope filter (scope always wins, so a declared filter can never widen access). - ContributionController: passes collection.filter to the reader, and stamps an action.defaults map over the whitelisted client payload AFTER the whitelist (so a client can never override a server-set discriminator).
… defaults for supertype schemas' (#45) from feat/portal-supertype-filter-defaults into development
…pts, notifications, inbox v2, downloads, session hardening v2, OIDC broker login)
docs(openspec): portal deepdive spec wave — 6 ff changes (WMEBV receipts, notifications, inbox v2, downloads, session hardening v2, OIDC broker login)
Closes the portal's upload/download asymmetry: a subject who could already
attach a file to their own row (filesUpload) can now retrieve one back.
- GET /portal/api/collections/{register}/{schema}/{id}/files/{fileId} streams
a file attached to an owned row after the SAME ownership + tenant + trust
re-verification as the scoped read (ContributionController::downloadFile()).
- Opt-in `filesDownload: true` per collection, normalised fail-closed exactly
like the existing `filesUpload` flag.
- Identical-404 discipline: non-opted-in, foreign-owned, and non-existent all
return the same body — no existence oracle, and the raw stored path is
never exposed (PortalFileReader delegates streaming to OpenRegister's own
FileService::streamFile() rather than re-implementing header sanitisation).
- Audit hook placed on the successful-download path (PortalAuditHook),
resolved by class name so it is a documented no-op until
portal-session-hardening-v2 registers the real AuditTrailService.
- SPA: object() attaches a safe `_files` listing when a collection opts in;
the detail view renders a download list (src/portal/components/PageView.jsx,
portalApi.js) fetched with the bearer auth header and saved via a Blob.
- Unit tests for the normaliser, the file reader, the audit hook, and the
controller's download path (opt-in gate, ownership-before-stream, identical
404s, audit invocation); Playwright e2e spec for the live download + 404
scenarios.
- Archived to openspec/specs/supplier-portal/spec.md (new canonical file).
…nload feat(portal): scoped document download (portal-document-download)
…ts (portal-inbox-v2)
Adds GET /portal/api/inbox (merges every kind:inbox collection across a
subject's contributions, sorted by receivedAt desc, provenance-tagged) and
PATCH /portal/api/inbox/{register}/{schema}/{id}/read (tamper-proof mark-read
— ownership/tenant/trust re-verified before any write, only `read` is ever
set). The contributions payload now carries the subject's own unread count.
The SPA gets an Inbox nav entry with an unread badge, a read-state toggle,
and renders the optional WMEBV art 2:10 readiness fields (aard/rechtsgevolg/
termijn) when a message supplies them.
Archives the portal-inbox-v2 OpenSpec change into the canonical
supplier-portal spec.
feat(portal): unified inbox v2 — aggregation, read state, unread counts
…ebv-submission-receipts) Adds the WMEBV (in force 2026-01-01) post-submit compliance layer to every successful portal create-action: SubmissionReceiptService writes a bilingual NL/EN ontvangstbevestiging (portalMessage, reusing the portal-inbox-v2 inbox) carrying a reference id, ISO-8601 timestamp, and a whitelisted copy of the submitted data, plus a linked portalSubmission append-only proof-of-receipt log satisfying the burden-of-proof duty. The receipt/log side-effect is fail-safe by construction — a write failure never fails the create, and a failed proof-log write retries with a minimal fallback record so the submission stays retriable. PortalManifestNormaliser gains a data-minimisation guard: a fieldConfigs `required: true` is honoured only when the field is in the action's own schema `required` set (resolved via PortalSchemaReader), dropped fail-closed otherwise — an electronic form may not require a non-mandatory field. Archives the wmebv-submission-receipts OpenSpec change into the canonical supplier-portal spec.
Pre-existing stylelint failure (declaration-empty-line-before), encountered while verifying this branch's checks — fixed per project convention of always fixing pre-existing quality issues hit during a task.
…portal-session-hardening-v2) - POST /portal/api/session/refresh rotates the bearer (new jti, old revoked) within an absolute max session lifetime (config session_max_lifetime, default 8h) measured from the original login (authTime carried unchanged across rotations); fails closed on revoked/expired/malformed/past-cap/unconfigured. SPA refreshes proactively every ~25min. - AnonRateLimit on the session endpoints and the scoped-CRUD/action surface; BruteForceProtection + throttle() on dev-login. - New portalAuditEntry schema + AuditTrailService: append-only, failure-isolated audit fact records on every mutation (create/update/forward), download, and session event (login/logout/refresh); count-only exposure via MetricsController. Makes the pre-existing PortalAuditHook (portal-document-download) go live. - Fixes a pre-existing bug in PortalSessionService::revoke() / revokeAllForOrganisation() calling PortalObjectWriter::updateObject() with a stale signature (3 PHPUnit errors, 10 phpstan errors, 12 psalm errors on development) — the file this change touches anyway.
…eceipts feat(portal): WMEBV submission receipts + data-minimisation guard
…al-session-hardening-v2 # Conflicts: # README.md # lib/Controller/ContributionController.php # openspec/specs/supplier-portal/spec.md # tests/Unit/Controller/ContributionControllerTest.php # tests/Unit/Settings/PortaliqRegisterConfigTest.php
…ion() Combining this change's forward-audit record() with wmebv-submission- receipts' receipt follow-on in the same handler pushed action()'s cyclomatic complexity to the phpmd threshold. Both side-effects legitimately belong on the one authorised-forward path; suppressed with the same rationale already used elsewhere in this class.
…ortal-notifications-dispatch) Consumes the contribution contract's previously-inert manifest `notifications` rule keys: when a portalMessage is created (including the WMEBV receipt) or a status-transition update succeeds, and the contributing app's manifest declares a matching rule key, NotificationDispatchService enqueues a background job (OCP\BackgroundJob\IJobList) that sends a privacy-minimal, bilingual NL/EN email nudge via OCP\Mail\IMailer — organisation name + deep link only, never case content, and never inline on the request path. Every attempt appends a new portalNotification log row (accountRef, ruleKey, channel, status, attempts, lastAttemptAt). After N consecutive failures the portalAccount is flagged needsAlternativeContact (WMEBV notificatieplicht ~Awb 2:11 fallback), cleared on the next successful send, and surfaced count-only via MetricsController (also fixes the pre-existing app_template metric-prefix bug to the app's own ADR-006 prefix). Adds the portalNotification schema + portalAccount.needsAlternativeContact to the register (0.4.0 -> 0.5.0), wires the trigger into SubmissionReceiptService and ContributionController::update(), and opts the demo contribution into both rule keys so the pipeline is exercisable end-to-end.
…ns-dispatch feat(portal): external notification dispatch with delivery logging (portal-notifications-dispatch)
…al-session-hardening-v2 # Conflicts: # lib/Controller/ContributionController.php # lib/Controller/MetricsController.php # lib/Settings/portaliq_register.json # tests/Unit/Controller/ContributionControllerTest.php # tests/Unit/Controller/MetricsControllerTest.php # tests/Unit/Settings/PortaliqRegisterConfigTest.php
…dening-v2 feat(portal): session refresh, rate limiting and portal audit trail (portal-session-hardening-v2)
…idc-broker-login) Replaces the dormant dev-login-only auth edge with a generic, broker-agnostic OIDC Relying Party: per-organisation broker config (issuer/clientId/secret via a dedicated sensitive IAppConfig entry/scopes/claim maps/loaMap), a state+nonce+PKCE authorization start, and a callback that fully validates the ID token (iss/aud/nonce/exp + RS256 via cached JWKS, alg:none rejected) before mapping claims and minting the existing HS256 portal session. Every failure returns the identical generic error; the subjectRef is always server-derived, never a request parameter; an unmapped LoA maps to trust low. Dev-login is unaffected. Adds the portalOidcState schema (register 0.7.0) and a generic identityType enum member; SPA login buttons now render per configured provider. Archives openspec/changes/portal-oidc-broker-login.
…ename authorizeEndpoint (renamed from authorizationEndpoint to satisfy phpmd's LongVariable) shifted the docblock column alignment PHPCS enforces; this was missed locally because phpcs was not re-run after that rename and the later SuppressWarnings additions. CI caught it — phpcbf re-alignment only, no logic change.
chore(deps): take hydra-gates v1.8.1 — the contract v1.8.0 shipped broken
The portal had no search. On a catalogue portal that is not a missing feature — it is how anybody finds anything, and the reference implementation this renderer mirrors puts it on the home page. Federation was also invisible. This instance holds 711 publications carrying @self.directory values of local, opencatalogi.nl and directory.opencatalogi.nl, and no page showed a visitor that more than one catalogue was involved. Every result now names its own directory, which is the first surface on which federation is observable at all — including for the operator checking whether it works. WHY NOT OPENREGISTER OBJECT SEARCH portal-public-search proposes exactly that and is blocked on openregister/openspec/changes/rbac-default-authenticated: 504 of 571 fleet schemas declare no authorization block and OR's default for an unmarked schema is still fail-OPEN. This is not blocked by that, and not as a workaround. OpenCatalogi runs the query, against schemas it owns, 37 of 37 of which declare their authorization — one of three apps in the fleet where that is true — and it calls OR with _rbac: true. Portaliq holds no visibility logic, no allow-list of searchable schemas and no second copy of the published predicate. portal-public-search remains open and its blocker is unchanged. The search box is the shared CnSiteSearch rather than a second copy of the reference's ac-search-box markup; it emits `search` and fetches nothing, which is the seam — the library owns the control, this block owns where the query goes. Transitive import walk over 11 files: no @nextcloud/* reached, checked by following relative imports rather than direct ones. DELIBERATELY NOT BUILT: a filter on the source directory. @self[directory]=opencatalogi.nl answers total: 0 on a corpus where all 711 rows populate that field, and _directory= is accepted and ignored. A control that silently empties the page is worse than an absent one — the visitor concludes the catalogue is empty and nothing contradicts them. The fix belongs in OpenCatalogi. The test covers the three things that fail SILENTLY: the facet envelope arrives in two dialects on the same endpoint and reading one leaves an empty column that looks like "no values"; a peer on an older schema sends a row that would otherwise blank the whole list; and an empty term must be omitted rather than sent as `_search=`, which is a different request. It is wired into check:specs, which is what CI runs. check:site-auth existed and no workflow ever called it, so it is wired in too — a test that never runs looks exactly like one that passes. COST, measured rather than estimated: +8,045 B gzipped (+7.8%), by building the same tree twice with only the WidgetGrid registration reverted. docs/portal-parity.md is re-measured in the same run. Its 2026-08-15 figures were stale by 84% gzipped — the site bundle baseline had grown 83% in five days with no entry recording it, which is the exact failure a parity document exists to prevent.
…60820060038 chore(release): 0.1.2-unstable.20260820060038
The stylelint config packages declare stylelint >=16 as a peer, so none can move alone — each single-package dependabot PR left the tree unresolvable and stylelint aborted before linting a file. Bumped together: stylelint ^17.14.1, @nextcloud/stylelint-config ^3.2.2, stylelint-config-recommended-scss ^17.0.1, keeping stylelint-config-recommended-vue at the ^1.6.1 that @nextcloud/stylelint-config@3.2.2 peer-requires. package-lock.json is REGENERATED rather than patched. The old lock pinned stylelint-scss with peer "stylelint ^14.5.1 || ^15.0.0", and npm will not move a transitive pin in place — every incremental install failed ERESOLVE against it. The majors the regeneration moves are all inside stylelint own subtree. npm run stylelint now exits 0.
…g at it revealed
Installing openregister, portaliq and opencatalogi in that order left a
correct, fully configured stack that rendered NOTHING — no portal resolved, so
every request 404d. Indistinguishable from a broken install to anyone who has
not read the CMS docs first.
`InitializeDemoPortal` seeds a Rotterdam-themed portal with a federated search
page at `/`, install-only and behind three guards, each closing a different
failure:
- Only when the instance has NO portals. An author may have deleted theirs
deliberately, and re-seeding on the next upgrade would second-guess them.
- A marker, because `countObjects()` returns 0 both when there are none and
when the count THREW — so "OpenRegister hiccupped" and "this instance is
empty" are the same value, and the empty one permits a write.
- It never fails the install. A repair step that throws aborts
`occ app:install`, trading a cosmetic problem for a total one — worst on an
offline install, the machine least able to report it. No network call either,
for the same reason.
IT SEEDS NO DOMAINS, so the portal does not serve until an operator binds a
hostname. PortalResolver has two modes and deliberately no fallback; marking a
domain verified here would assert control of a hostname on the operator's
behalf, which is what that flag exists to prevent. The demo rig binds localhost
itself, as a deployment decision on a throwaway box.
FOUND BY RUNNING IT ON A FRESH INSTALL, in order:
- `'portal' => $portalId` matched nothing. Content is scoped by portal SLUG,
not id — the portal resolved, served its header and footer, and rendered
"Pagina niet gevonden" between them, with every object present and published.
- `'props' => []` is rejected ("expects object but got empty ({}) ... set this
to null"), and `'props' => null` is rejected too ("should be type 'object' but
is 'null'"). The first message recommends exactly what the second refuses.
Either way the page write fails and the portal renders its own chrome around
a 404.
FOUND BY LOOKING AT THE PAGE, which the accessibility tree had shown as fine:
- `.pq-search__layout` reserved a `180px 3fr` grid for a facet column that does
not render, so results landed in the 180px track with 514px empty beside
them — measured, one child, and every result wrapped into a ribbon. Facets
are absent on every federated search, so this was the DEFAULT state.
- `.utrecht-card__heading` carries `order: 2` — that component puts its label
above its title by design — so each result showed its summary and source
ABOVE its own title, at 40px. The title now states its own order and uses
`heading-4` for size while staying an `h3`, because the outline needs the
level and 40px is a page title, not a row in a list of eleven.
- `utrecht-article` is a PROSE measure. Rotterdam's is 750px, straight from
RODS, and correct for running text — applied to a widget grid it clamped the
page to 750px at x=0 while the header and footer sat at x=40 and 1200px. A
grid is not an article.
- "1 resultaten gevonden". A portal that cannot count to one in its own
language is the first thing a citizen notices about it.
The block's own heading is now empty by default: the renderer already prints
the page title, and two `h2`s saying "Zoeken" and "Zoeken in publicaties" are
two section headings where the page has one section.
`facetField` defaults to `themes`, which the publication schema DECLARES. Only
a declared property can be faceted; `categories` belongs to the publiccode
schema, and faceting on it returned rows that showed their categories and
faceted to nothing.
…bump build(deps-dev): bump stylelint to 17 as one coupled upgrade
Two user-visible defects are fixed by this bump. TWO AI-COMPANION HEXES ON EVERY PAGE. The companion singleton landed in 2.7.0. Below that the host app's own companion never stands down, so any page of this app rendered a second hex 8px from hermiq's — measured on a running instance: openconnector (2.7.1) showed ONE, openbuild (2.6.3) showed TWO, both visible at 52x60, from two separate mounts. THE DETAIL PAGE RECLOSED ITS SIDEBAR WHILE HYDRATING. CnDetailPage set sidebarSeeded and never read it, so 'open' was re-applied on every sync and each reactive change during hydration reset it to the prop default. Fixed in nextcloud-vue#711; that is the cause behind openbuild#268 and, on the evidence, #188. Lockfile only — the existing caret already allowed this. Three-line diff (version, resolved, integrity).
Reproduces opencatalogi.nl's three page types against the demo rig's own federated data, themed by the new `opencatalogi` token set. LANDING PAGE. The portal had none — `/` was the search. Now it is a hero band with the prompt and a search box, then a section, matching the reference; search moved to `/zoeken` and the hero's box navigates there carrying `_search`, so the landing box and the search box produce the same shareable URL. The hero's `search` event was already emitted by the shared block and wired to NOTHING. The box submitted, the event reached WidgetGrid, and nobody above ever heard it — an inert control on the most prominent element of the page. SEARCH PAGE, measured against the reference at 1280px: breadcrumb, an `h2` result count, a Sorteren select, per-card date and source metadata with "Lees meer over <title>", and `1 2 3 4 5 … 36` pagination including the gap marker. - NO "Meest relevant" sort option. The reference offers one; nothing in this API implements relevance ordering. The four that remain were each checked against the live endpoint and return a different first row. - The live region says "Zoekresultaten". The reference says "Zoekresulten"; a typo is not a pixel. - Card titles are TEXT, not links — measured on the reference as a `<span>` at rgb(0,0,0) weight 400, with "Lees meer over X" as the only link. Rendering the title as a link too produced a green underlined heading matching nothing, and the reference's shape is also the better one: a link styled exactly like body text has no affordance. DETAIL PAGES at `/publicatie/<id>`, faithful to the reference including the fields that arguably should not be there. `loadRoute` falls back to the PARENT route once, on a 404 only, and hands the trailing segment down as `routeParam` — one page, thousands of subjects. Walking further up would make `/does/not/exist` render the home page. `?_id=<uuid>` IS ACCEPTED AND IGNORED: measured, it answered `total: 11` and returned a different publication first, so with `_limit=1` the page would have shown the wrong publication confidently. The by-id route `/api/federation/publications/<uuid>` returns the object, and `matchesId()` still checks — that guard is why this surfaced as "not found" rather than as the wrong page. THE LOGO NEEDED PHP. Token sets declare `--nldesign-logo-url` relative to the token file, and a browser resolves a relative `url()` inside a custom property against the stylesheet CONSUMING it — this app's bundled CSS. The header requested /custom_apps/portaliq/img/logos/opencatalogi.svg and rendered no logo while every token held the right value. `PortalThemeResolver::logoFileFor()` plus an absolute override in the template fixes it for every brand. Sub-footer placement is now a contract — 0 header, 1 footer column, 2+ legal strip — rather than "the highest position, when there are at least two", which made a legal strip impossible without also inventing a footer column the reference does not have. BUNDLE. `webpack.site.js` sets `performance.hints: 'error'` at 400 KiB because a first-time visitor on a phone pays for every byte. This work took the entry to 445 KiB and FAILED THE BUILD, which is the budget working. Both route-specific blocks are now loaded on demand, so the entry is 395,201 B / 114,158 gzipped against a 393,627 / 113,459 baseline — the whole feature set costs the landing visitor roughly nothing, and search (49 KB) and detail (15 KB) load where they are used.
chore(deps): take @conduction/nextcloud-vue 2.8.2 (was 2.8.1)
…g it The cards, pagination and metadata row were close and not the same, because I had rebuilt them from screenshots rather than from the structure they are styled by. `nlds-app.css` in this app IS the stylesheet opencatalogi.nl serves, so the card now emits the classes that stylesheet already targets — `ac-card ac-card--search-result ac-card--padding-md` wrapping `ac-card__content`, with the meta row as `ac-flex ac-flex--justify-content-between`. Padding, radius and the inner column gap then come from the same rule the reference uses, rather than from my approximation of it. Measured before and after, ours against the reference: card background transparent → #ffffff (reference #ffffff) card radius 0 → 8px (8px) inner padding 16px → 24px (24px) inner gap — → 12px (12px) title heading-4 → 32px/400/black (32px/400/black) meta text 12px/700 → 12px/700 (12px/700, #003865) pagination button — → 48x48 (48x48, current #e6f6ff) Two details I had simply missed: the reference separates metadata items with an 8px `#B9E4FC` circle, and its "read more" control is a 16x14 ARROW with the words carried in an `sr-only` span — not the visible text "Lees meer" I had put there. Both are now emitted as the reference emits them, so the link is still announced as "Lees meer over <title>" while looking like the arrow it is. `metaItems()` assembles the row so the dot goes BETWEEN items rather than after each — a trailing dot on the last item is the version that looks fine until somebody notices it.
…62-line method
CI refused this branch for two reasons and both were fair.
`InitializeDemoPortal::run()` had grown to 262 lines against phpmd's threshold,
because every new seeded object went into the same method. It is now an
orchestrator over `createPortal()`, `createHomePage()`, `createDetailPage()`,
`createLegalMenu()` and `createSearchPage()` — the guards and the failure
handling stay in `run()`, where they belong, and each object's payload is
readable on its own.
The coverage guard measured "coverage of the files this change touches dropped
by 58.47%; this change adds 188 statements". That is exactly what it is for: I
added an install hook that writes content and a theme-logo resolver, with no
PHP tests at all.
What the new tests assert is what must NOT happen, because the happy path
announces itself and the refusals do not:
- the marker refuses a second run EVEN WHEN THE COUNT SAYS ZERO, which is the
case that matters: countObjects() catches Throwable and answers 0 both when
the instance is empty and when the count failed, and 0 is the answer that
permits a write
- an instance with existing portals is never touched
- a failed write warns and returns rather than throwing, because a repair
step that throws aborts `occ app:install` and leaves the app not installed
- the marker is NOT stamped on a half-provisioned instance, so the re-run
that would complete it stays possible
- every page references the portal by SLUG — the failure that rendered the
portal's own chrome around a 404 with every object present and published
- pages are asserted by ROUTE and by WIDGET KEY, not by count: three pages
with the wrong routes passes a count
For the logo resolver: a catalogued set with no logo file resolves to null
rather than a path that 404s, and a theme that does not resolve has no logo
either — the mark must never outlive the theme it belongs to.
Verified locally: 13 new tests, 546 → 559. The 24 errors in the full run are
three pre-existing classes that need a Nextcloud server tree the worktree does
not have; none of them is new here, and CI runs that suite green.
…ed it was
Eight site specs went red — S1, S3, S14, S16b, S19, S21, S23, S11 — all on
`getByTestId('widget-grid')` not found. The grid renders correctly on a live
rig, so the break was in the fixture, not the renderer.
`seed-cms.sh` upserts pages by ROUTE ALONE. It seeds two portals on purpose
(open-tilburg and open-venray, so cross-portal isolation is testable), and
portaliq's install hook now seeds a third with its own page at `/`. The lookup
for open-tilburg's home page matched the DEMO portal's `/` page, the existing
scope guard passed — that object does carry a `portal`, just not this one — and
upsert reported "already seeded" without creating anything.
open-tilburg then resolved, served its header and footer, and answered 404 for
`/`. Every spec that opens the site lost its content in one go.
The seeder now matches on portal AND key, with the portal read out of the
payload being seeded. That was always the correct key: this fixture creates two
portals itself, so route was never unique in it — the collision just needed a
third portal to become reachable.
Also closes the remaining coverage gap the ratchet flagged (2.29% over 191
statements) with three controller tests for the theme logo URL:
- a themed portal whose set ships a logo gets an ABSOLUTE url — the case that
exists because a relative `url()` inside a custom property resolves against
the CONSUMING stylesheet, so the header requested the logo from portaliq's
own directory and rendered nothing while every token was correct
- a set with no logo file emits '' rather than a path that 404s, because a
broken image looks exactly like no logo
- an unthemed portal emits no logo either: the mark must never outlive the
theme
The harness needed `linkTo()` stubbed to assert this at all — without it the
"absolute url" assertion would have passed on an empty string.
Local: 546 → 562 tests. The 24 errors in the full run are three pre-existing
classes needing a Nextcloud server tree the worktree does not have.
Release: merge development into beta
#158 bumped phpstan/phpstan alone and failed. The failures were migration behaviour, not defects, and are fixed once for the fleet in ConductionNL/.github#517 — released as conduction/hydra-gates v1.8.2: treatPhpDocTypesAsCertain: false 2.x narrows types declared only in a docblock, then reports every defensive check against them as already-decided. ignoreErrors phpDoc.parseError @SuppressWarnings is PHPMD's tag; 2.x parses it as its own and fails on PHPMD's rule syntax. This app's lockfile pinned hydra-gates v1.8.1, so that config was not in effect. Bumping both is the whole change — portaliq needs NO code fixes: phpstan 2.2.8 reports zero errors on it. Verified: phpstan exit 0; control — a deliberately broken file still reports 'Found 1 error', so it is analysing rather than silently skipping. phpcs exit 0.
…ranches gate-16 reported 28 changed methods without an `@spec` anchor. Each is now tagged against the requirement it actually serves rather than a blanket anchor: the race guard in `search()` points at the superseded-request requirement, `matchesId`/`load` at the malformed-row one, `openDetail` at the name-the-catalogue one. Three members had no docblock at all — `bodyIsGrid`, `bodyProvidesHeading` and `hrefForRoute` had been separated from theirs by an earlier edit — and the `subjectId` WATCHER (not the prop of the same name) was carrying a `//` comment the gate cannot read. All four now have real docblocks. Also covers the branches of `InitializeDemoPortal::run()` that nothing reached: a portal written without an id, a failed home page, and a failed search page. Each asserts the MARKER STAYS UNSTAMPED, which is the property that matters — the marker is what makes the step refuse a second run, so stamping it for a half-provisioned portal would make the incomplete state permanent by turning away the re-run that would have finished it. The search-page case is the least visible and the worst: portal, landing page and menu all exist, so the install looks successful and the site renders, with no search. Verified: gate-16 PASS (63 of 63 applicable gates ran; the two remaining WARNs, gate-23 AuditTrailService and gate-53 CustomExample, are pre-existing and untouched here). lint/phpcs/phpmd/psalm/phpstan pass; check:specs, check:manifest and format pass; build exits 0 at 398,595 B. Not verified locally: the coverage ratchet. No pcov or xdebug is reachable on this box without root, so the scoped ratchet number comes from CI.
EVERY Release run this repo has ever had failed — beta and development alike — while Code Quality stayed green. The error names a package nothing here depends on directly: npm error `npm ci` can only install packages when your package.json and npm error package-lock.json ... are in sync. npm error Missing: pinia@4.0.3 from lock file The lock is not out of sync. The shared release workflow defaults `node-version` to 22, every Node 22 release bundles npm 10 (22.23.2 ships 10.9.8), and npm 10 cannot read this lock. quality.yml already defaults to 24, which is the whole reason one workflow is green and the other is not — the two shared workflows drifted, and release.yml's comment still claims it is "kept in step" with quality.yml. Measured here on BETA'S EXACT BYTES, both npms against the same files: npm 11.19.0 -> exit 0, added 1390 packages npm 10.9.8 -> exit 1, EUSAGE, Missing: pinia@4.0.3 from lock file Worth recording that the same test against THIS BRANCH's lock passes on both npms — the two locks differ. Releases build from beta, so beta's lock is the one that decides, and a control run in the working branch would have cleared a fix that does nothing. opencatalogi measured the identical pair on 2026-08-17 and carries the same three lines. launchpad has this defect too (same all-red Release history, same missing input) and is left alone here as out of scope for this change.
Every outward link in info.xml named Codeberg — documentation, website, discussion, bugs, repository and the screenshot. The fleet dropped Forgejo/Codeberg on 2026-08-19 and GitHub is the only forge again. Nothing was broken enough to notice: all six Codeberg URLs still answer 200 from a stale mirror, including the screenshot (image/svg+xml). That is precisely why it survived — a dead link would have been found long ago, while a live link to a forge nobody reads just quietly sends bug reporters somewhere no one is listening. It matters now because this app has never been published: it is absent from apps.nextcloud.com entirely, so its first beta release is what turns these links into its public identity rather than an internal detail. Verified: info.xml validates against apps.nextcloud.com/schema/apps/info.xsd; the new screenshot URL answers 200 image/svg+xml from raw.githubusercontent on `main`; check:manifest passes; no `codeberg` string remains in the file. `src/manifest.json`'s `$schema` still names a Codeberg URL. Left alone on purpose — validate-manifest.js keys its "schema variant: v2" detection off that exact string and resolves the real schema from node_modules, so it is an identifier rather than a fetched link. The same staleness is fleet-wide in opencatalogi, nldesign and openregister's info.xml and is not touched here.
build(deps-dev): take phpstan 2 — no code changes needed (supersedes #158)
…-federated-search-portal
…arch-portal Federated publication search on the public portal
Release: merge development into beta
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
rubenvdlinde
requested review from
Rem-Dam,
SudoThijn,
WilcoLouwerse,
bbrands02,
remko48 and
rjzondervan
as code owners
August 27, 2026 08:26
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.
Promotes
betatomain, which builds and tags the first stable release of Portaliq.Why now
The demo composes being written for the Connext ecosystem install apps from GitHub release tarballs, because a release tarball is a complete app (it carries
vendor/and builtjs/) while a git clone is not. Portaliq has no stable tag at all — only*-unstableprereleases, whichreleases/latesthides — so a demo compose cannot pin it.Portaliq is required for the OpenCatalogi demo: it renders the public portal that the seeded demo catalogue is displayed through.
Scope
betais 327 commits ahead ofmainand 0 behind, so this is a clean promotion with no divergence to reconcile.Note this does not carry the newest
developmentwork:development → beta(#259) isCONFLICTING, and that conflict is tracked separately. Shipping a stable release frombetais the documented path and does not depend on it.