Skip to content

release: first stable release of Portaliq - #263

Merged
rubenvdlinde merged 327 commits into
mainfrom
beta
Aug 27, 2026
Merged

release: first stable release of Portaliq#263
rubenvdlinde merged 327 commits into
mainfrom
beta

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Promotes beta to main, 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 built js/) while a git clone is not. Portaliq has no stable tag at all — only *-unstable prereleases, which releases/latest hides — 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

beta is 327 commits ahead of main and 0 behind, so this is a clean promotion with no divergence to reconcile.

Note this does not carry the newest development work: development → beta (#259) is CONFLICTING, and that conflict is tracked separately. Shipping a stable release from beta is the documented path and does not depend on it.

rubenvdlinde and others added 30 commits July 12, 2026 16:15
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.
rubenvdlinde and others added 23 commits August 20, 2026 07:55
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)
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants