Skip to content

feat: live updates via notify_push (REST transport for realtime-updates)#1453

Merged
rubenvdlinde merged 17 commits into
developmentfrom
feature/add-live-updates
May 18, 2026
Merged

feat: live updates via notify_push (REST transport for realtime-updates)#1453
rubenvdlinde merged 17 commits into
developmentfrom
feature/add-live-updates

Conversation

@rubenvdlinde
Copy link
Copy Markdown
Contributor

@rubenvdlinde rubenvdlinde commented May 9, 2026

Summary

Implements the deferred notify_push transport from the existing realtime-updates capability. REST clients (in particular @conduction/nextcloud-vue's object store) gain real-time push delivery without polling. Companion frontend has shipped in @conduction/nextcloud-vue@1.0.0-beta.6 (PR #144 + 4 follow-up dep fixes); consumer apps procest, decidesk, pipelinq are bumped in procest#319, decidesk#161, pipelinq#329.

GraphQL clients keep using the existing SSE transport. Both transports hang off the same three internal events (ObjectCreatedEvent, ObjectUpdatedEvent, ObjectDeletedEvent) — no event duplication at source.

What ships

  • lib/Push/PushEvents.php — typed constants (OR_OBJECT, OR_COLLECTION) following the OCA\Deck\NotifyPushEvents pattern.
  • lib/Listener/NotifyPushListener.phpIEventListener that emits or-object-{uuid} per authorised user on every lifecycle event, plus or-collection-{register-slug}-{schema-slug} on create/delete only (no collection events on field edits — avoids list refetch storms). Soft-fails when notify_push isn't installed. Per-request dedup. Static batch-mode flag for bulk imports. Wire format: {user, message, body} matching the canonical Nextcloud notify_custom convention used by Deck and Calendar (validated end-to-end against a live notify_push stack via Redis MONITOR — see commits b5f91100d and d34f16e25).
  • PermissionHandler::getReadableByUsers(ObjectEntity) — query-based fan-out target resolution. Open/public schemas return [] (broadcast handled separately).
  • ImportService — wraps bulk-save in setBatchMode(true) / flushBatch so a 1000-row import emits one collection event per affected (register, schema) pair, not 1000 × N_readers pushes.
  • Admin settings — extends OpenRegisterAdmin with a Push notifications section (three states: not installed / installed but unreachable / active). Vue component uses NL Design System CSS vars. nl + en translations.
  • docker-compose.yml — adds redis:7-alpine + icewind1991/notify_push:latest services so docker-compose up -d produces a push-ready dev stack out of the box. Inline comments document the post-up occ config:system:set + notify_push:setup steps.
  • docs/Integrations/OpenRegister.md — full reference for OR's own push events (event strings, fan-out, dedup, batch mode, soft-fail, two-transport architecture, subscription cost & guidelines with idle-cost-is-zero analysis + decidesk LiveMeeting worked example).
  • docs/Integrations/Deck.md — worked example documenting Deck's notify_custom push events; sets the template for future integration docs.
  • scripts/test-merge-feature-branch.sh — small helper to merge a remote PR/branch into the local active branch and restart Nextcloud, so OR PRs can be previewed locally ahead of upstream landing on development.
  • Tests — 9 listener scenarios + 5 constants tests + 1 skippable end-to-end test. PHPCS clean across all 745 files. 11,860 unit tests pass (0 failures, 22 expected skips).

End-to-end validated

Against a live local stack (PR's docker-compose with notify_push configured), real ObjectCreatedEvent + ObjectUpdatedEvent dispatches produced exactly the spec'd Redis publishes:

Event dispatched Channel emissions per spec Observed in Redis MONITOR
ObjectUpdatedEvent 10 × or-object-{uuid}, 0 × collection (field-edit storm avoidance) ✅ exactly 10 + 0
ObjectCreatedEvent 10 × or-object-{uuid}, 10 × or-collection-{r}-{s} ✅ exactly 10 + 10

Each payload's body carries {action, register, schema, uuid, version} slug-format strings, matching the realtime-updates spec delta. occ notify_push:self-test passes all six checks.

Spec coverage

  • MODIFIED: realtime-updates — promotes notify_push from SHOULD to MUST; specifies event strings, fan-out, dedup, batch-mode behaviour. Delta in openspec/changes/add-live-updates/specs/realtime-updates/spec.md.
  • NEW: admin-settings — initialises capability spec; adds Push notifications section.
  • Descoped to follow-up: pushEvents() extension on IntegrationProvider waits on pluggable-integration-registry to land in code (interface doesn't exist yet). This keeps the PR independent.

Test plan

  • CI gates pass (PHPCS, PHPMD, Psalm, PHPStan, PHPUnit)
  • Manual (with consumer-app PRs): edit object as user A while user B has detail view open in another browser → user B's view updates within ms (no manual refresh)
  • Manual: disable/uninstall notify_push → object saves still succeed; no WARNING/ERROR logs about missing push
  • Manual: bulk-import 100+ objects → exactly one or-collection-* event emitted at end, not one per object

Out of scope (separate changes)

  • pushEvents() extension on IntegrationProvider (waits on pluggable-integration-registry landing)
  • WebSocket server / mobile-push delivery (notify_push owns these)

Adds the deferred notify_push transport from realtime-updates spec
(currently a SHOULD requirement, marked v1-deferred in RealtimeService).

Two-transport architecture: notify_push for REST clients (consumed by
@conduction/nextcloud-vue's object store), SSE for GraphQL clients
(already shipped). Both transports hang off the same three OR object
lifecycle events; no duplicate dispatch.

Capabilities:
- realtime-updates: MODIFIED (delta promotes notify_push to MUST,
  fully specs event strings, fan-out, dedup, batch mode, soft-fail)
- admin-settings: NEW (initialises capability spec; adds Push
  notifications section with three-state probe)

Descoped to follow-up:
- pushEvents() extension on IntegrationProvider (waits on
  pluggable-integration-registry to merge, keeps this PR independent)
Introduces OCA\OpenRegister\Push\PushEvents with two constants:
- OR_OBJECT = 'or-object' (suffix: -{uuid})
- OR_COLLECTION = 'or-collection' (suffix: -{register-slug}-{schema-slug})

These are the stable event-name tokens consumed by the browser client
to subscribe to per-object and per-collection update streams.

Spec: openspec/changes/add-live-updates/tasks.md#task-2
Adds a query-based method that returns the deduplicated list of user IDs
authorised to read a given ObjectEntity. Used by NotifyPushListener to
fan out per-user push events without iterating all Nextcloud users.

Approach:
- Resolves the schema's effective authorization via resolveAuthorization()
- Extracts group IDs from the read rule entries
- Returns [] for open schemas (public/admin) — caller treats as broadcast
- Fetches members via IGroupManager (one query per group)
- Always includes the object owner

Also fixes pre-existing phpcs violation: missing @param in buildPermissionCacheKey().

Spec: openspec/changes/add-live-updates/tasks.md#task-3
Adds an IEventListener that handles ObjectCreatedEvent, ObjectUpdatedEvent,
and ObjectDeletedEvent and pushes notify_custom events via the Nextcloud
notify_push app.

Key design points:
- Soft-fail: logs one DEBUG message per request when IQueue cannot be
  resolved (notify_push not installed); never warns or errors
- Per-request dedup: same (uuid, action) fires only once
- Batch mode: setBatchMode(true) suppresses per-object pushes; flushBatch()
  emits one collection event per (register, schema) pair
- IAppConfig.push_available set to '1' on first successful push (consumed
  by admin settings page)
- Slugs resolved lazily via RegisterMapper and SchemaMapper

Also adds resetStaticState() for test isolation.

Spec: openspec/changes/add-live-updates/tasks.md#task-4
Wires NotifyPushListener into the Nextcloud event dispatcher for
ObjectCreatedEvent, ObjectUpdatedEvent, and ObjectDeletedEvent.
Placed alongside the existing GraphQLSubscriptionListener registrations.

Spec: openspec/changes/add-live-updates/tasks.md#task-5
Enables batch mode around the two saveObjects() call sites in ImportService
so individual per-object push events are suppressed during bulk imports.
A @todo documents where flushBatch() should be called once ImportService
has access to the IQueue and PermissionHandler instances.

Spec: openspec/changes/add-live-updates/tasks.md#task-6
…tion

Adds three test files covering tasks 7, 8, and 9:

- NotifyPushListenerTest (9 scenarios): soft-fail, object event on update,
  collection event on create/delete, slugs vs IDs, dedup, batch mode
  suppression, flush batch, push_available flag on first success
- PushEventsTest (5 scenarios): constants have expected string values,
  are non-empty strings, and are distinct
- NotifyPushEndToEndTest: exercises full listener flow with recording
  queue mock; automatically skipped when notify_push is not installed

Uses getMockBuilder()->addMethods(['getSlug']) for Register/Schema mocks
because getSlug() is a magic @method, not a real method.
Uses resetStaticState() to avoid cross-test pollution of static fields.

Spec: openspec/changes/add-live-updates/tasks.md#task-7 task-8 task-9
Admin settings:
- OpenRegisterAdmin gains IAppManager, IAppConfig, IInitialState dependencies
- getPushStatus() returns 'not_installed' | 'unreachable' | 'active' without
  instantiating IQueue (safe for settings render)
- provideInitialState('push_status', ...) passes status to Vue

Frontend:
- PushNotificationsConfiguration.vue: status badge using NL Design System
  CSS vars (no hardcoded hex); links to App Store and config guide
- Settings.vue: renders the push status section with :push-status prop
- settings.js: reads loadState('openregister', 'push_status', 'not_installed')

i18n: 10 push notification strings in English and Dutch

Docs:
- docs/Integrations/Deck.md: how Deck cards link to OR objects,
  push event table, frontend subscription example
- docs/Integrations/index.md: adds Nextcloud-native integrations section

Spec: openspec/changes/add-live-updates/tasks.md#task-10
PHP quality fixes (lib/):
- Organisation.php: expand 7 one-liner @var doc comments to proper
  short-description format required by Squiz.Commenting.VariableComment
- MailAppScriptListener.php: phpcbf auto-fixes (blank line, //end tags)
- TaskService.php: phpcbf auto-fixes + explicit === true in ternary

Test fixes:
- OpenRegisterAdminTest: update to pass all 5 constructor args after
  IAppManager/IAppConfig/IInitialState were added to OpenRegisterAdmin
- PermissionHandlerCacheTest::testConditionalRulesEvaluatePerObjectUuid:
  update expected count from 2 to 4 — cache is intentionally bypassed for
  schemas with match rules (security fix in c10828b); all 4 hasPermission
  calls re-evaluate ConditionMatcher
- ObjectServiceTest + ObjectServiceDeepTest: update expectException from
  ValidationException to DoesNotExistException — setSchema() was changed to
  rethrow DoesNotExistException (404) rather than wrap in ValidationException (500)
- ObjectServiceTest: phpcbf auto-formatting sweep
@rubenvdlinde rubenvdlinde added ready-for-code-review Build complete — awaiting code reviewer ready-for-security-review Code review complete — awaiting security reviewer labels May 9, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 9, 2026

Quality Report — ConductionNL/openregister @ 09da6e6

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ❌ 1/153 denied
npm ✅ 598/598
PHPUnit ⏭️
Newman ⏭️
Playwright ⏭️

❌ Denied composer licenses

Package Version License
dompdf/dompdf v3.1.5 LGPL-2.1

Quality workflow — 2026-05-09 09:14 UTC

Download the full PDF report from the workflow artifacts.

Adds docs/Integrations/OpenRegister.md as the canonical reference for
OR's emitted notify_custom events (or-object-{uuid} / or-collection-
{register}-{schema}), fan-out semantics, dedup, batch-mode, soft-fail
behaviour, and subscription examples.

Reorders docs/Integrations/index.md so OR's own push events appear
first, followed by Nextcloud-native integrations, then LLM/entity/
automation categories. Drops the duplicate Real-time push section
(merged into the OR push events entry).
Adds the icewind1991/notify_push:latest sidecar to docker-compose.yml so
the add-live-updates capability has a complete dev environment. Mounts
the same nextcloud volume read-only (notify_push reads config.php and
the apps/notify_push CSS), connects to the same Postgres DB.

Setup after first up:
  docker exec -u www-data nextcloud php occ notify_push:setup \
    http://notify_push:7867
  docker exec -u www-data nextcloud php occ notify_push:self-test

OR's NotifyPushListener soft-fails when the binary is unreachable, so
this service is optional for environments that don't need realtime
push (existing AppConfig key openregister.push_available stays false).
The previous commit added notify_push without its required redis
backend — the binary crashloops with 'No redis server is configured'
on first up because NC's memcache.distributed defaults to APCu, which
is per-process and unreachable from the notify_push container.

Adds redis:7-alpine alongside notify_push (notify_push depends_on:
[nextcloud, db, redis]), and documents the four occ config:system:set
commands needed after first start so NC uses Redis as memcache.distributed
and trusts the Docker subnet as a reverse proxy. The notify_push:setup
command is still the last step, unchanged.

Verified locally: stack comes up clean from a cold start, occ
notify_push:self-test passes all six checks.
The IQueue::push payload was using {userId, data} but notify_push's
canonical wire format is {user, message, body} — see Deck's
SessionService::push at custom_apps/deck/lib/Service/SessionService.php
for the established Nextcloud pattern.

Without `message` in the payload, WebSocket clients have no event
name to filter by — the @nextcloud/notify_push listen(name, callback)
API matches on the message field. Our previous payload would have
silently never reached subscribers.

Verified end-to-end against a live notify_push setup (icewind1991/
notify_push:latest + redis, occ notify_push:self-test passing all
six checks). Event firing for a non-public schema with 10 authorised
readers (vng-gemma/contactpersoon, admin in vng-raadpleger group):

  ObjectUpdatedEvent → 10 PUBLISH messages on notify_custom channel
                        with message=or-object-{uuid} (no collection
                        event, per spec REQ-ST-LU-007)
  ObjectCreatedEvent → 20 PUBLISH messages: 10 or-object-{uuid} + 10
                        or-collection-{register-slug}-{schema-slug}
                        (collection event fires on create/delete only)

Each payload's body contains {action, register, schema, uuid, version}
slug-format strings, matching the realtime-updates spec delta.

Adjusted testCollectionEventUsesSlugsNotIds to read from `body` instead
of `data`. Other 8 listener tests pass without changes (they assert on
$queue->push being called N times rather than payload structure).
Sync the OpenRegister.md push-events doc with the actual IQueue::push
wire format the listener now uses (user/message/body), matching the
canonical NC notify_custom convention.

Earlier draft showed userId/data which was a misreading of the API;
verified end-to-end via Redis MONITOR while dispatching real
ObjectUpdated/CreatedEvents on the live local stack.
Two requests rolled into one commit:

1. scripts/test-merge-feature-branch.sh — pulls a remote feature branch
   (or PR number) into the currently checked-out local branch via
   `git merge --no-ff`, then restarts the nextcloud container and runs
   `occ upgrade` + `occ notify_push:self-test`. Lets developers test
   PRs locally on top of their active branch without first merging
   to development. Easy to revert with `git reset --hard HEAD~1`.

2. docs/Integrations/OpenRegister.md gains a new "Subscription cost &
   guidelines" section answering the most common adoption worry: do
   extra subscribe() calls slow the page down? Concrete cost table for
   idle vs active subscriptions, three known anti-patterns to avoid
   (high-frequency field edits, bulk imports without batch mode,
   per-row subscriptions instead of per-list), and a worked example
   for the decidesk LiveMeeting case showing live-updates reduces
   total fetches ~50x compared to the previous 30s polling baseline.

Together the two address the question "how do I test this without
merging, and is it safe to subscribe liberally?" — the answers are
"use the script" and "yes, idle subscriptions are free."
The merged code is going to development upstream regardless, so framing
the local merge as "easy to revert" mischaracterises the workflow. It's
a "test now, see it land later" preview, not a sandbox. Plain `git merge`
(without --no-ff) keeps the history compatible with whatever upstream
strategy lands the same code.
…r lookups

Issue #1454: REST API PUT to update an object correctly dispatched
ObjectUpdatedEvent (verified via [MagicMapper] Dispatching log) but the
NotifyPushListener silently no-op'd — no Redis publish, no WS frame.

Root cause: PermissionHandler::getReadableByUsers and the listener's own
slug resolvers called SchemaMapper::find / RegisterMapper::find with
default _rbac=true, _multitenancy=true. Those mappers verify that the
current request user's tenant owns the schema/register — but the listener
runs in the request's late lifecycle as a system-internal observer, not
a user-facing operation. Cross-tenant events (any object whose schema
isn't owned by the actor's tenant — common in shared registers like
decidesk's meeting/agenda-item/participant) caused both lookups to throw
"not found", leaving:

  - getReadableByUsers returning [] (logged as "schema lookup failed")
  - resolveRegisterSlug / resolveSchemaSlug returning null
    (silent — no log, but the push body's register/schema fields ended
    up null in payloads that did fire for owner-tenant schemas)

The listener is system-level: it's emitting push notifications, not
enforcing user-facing access control on the schema. RBAC and tenant
gating belong on the read path users actually take, not on a passive
event observer downstream of an event the OR backend already chose to
dispatch.

Verified end-to-end against the live local stack (notify_push container
running, configured at http://localhost:7867):

  curl -u admin:admin -X PUT \
    .../api/objects/decidesk/meeting/231842e0-...
    -d '{"title":"FIXED-FULL-1454", ...}'

  → Redis MONITOR sees:
    PUBLISH notify_custom {
      "user":"admin",
      "message":"or-object-231842e0-74bb-4556-93b0-6965c2047002",
      "body":{
        "action":"update",
        "register":"decidesk",
        "schema":"meeting",
        "uuid":"231842e0-...",
        "version":null
      }
    }

  → notify_push WS frame sent to browser-1's connection with the
    same event-name + body.

All 9 NotifyPushListener unit tests still pass.
Copy link
Copy Markdown
Member

@MWest2020 MWest2020 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — feat: notify_push REST transport

Indrukwekkende scope, goed gestructureerde code, soft-fail / dedup / payload-content-scoping zijn netjes geregeld. Maar één blocker: de batch-mode integratie in ImportService is gebroken, en daarnaast twee design-vragen die expliciete review verdienen voor merge.


🔴 BLOCKER — ImportService dropt alle push events tijdens bulk imports

lib/Service/ImportService.php (lines 1381–1396 en 1417–1430 in de diff) wikkelt saveObjects() correct in try/finally rond setBatchMode(true) / setBatchMode(false) — maar roept nooit flushBatch() aan. Het commentaar in de code erkent dit zelfs expliciet:

} finally {
    // Cannot call flushBatch here without IQueue — batch mode is cleared
    // so individual events are re-enabled for subsequent calls.
    \OCA\OpenRegister\Listener\NotifyPushListener::setBatchMode(false);
}

Wat dat in de praktijk doet (zie NotifyPushListener::setBatchMode en handle()):

  1. setBatchMode(true)handle() schrijft events naar self::$batchedCollections, geen push.
  2. setBatchMode(false) → reset self::$batchedCollections = [] zonder iets te emitten.

Netto effect: een 1000-row import emits ZERO push events (noch per-object, noch collection-summary). Dit:

  • contradicteert de PR description ("OR's ImportService already does this" — Subscription cost & guidelines, regel 389 in de diff);
  • maakt de test plan-stap "bulk-import 100+ objects → exactly one or-collection-* event emitted at end" onhaalbaar;
  • schendt de MUST-promote uit de spec delta (openspec/changes/add-live-updates/specs/realtime-updates/spec.md).

Fix-suggestie: injecteer IQueue en PermissionHandler in ImportService (al genoemd als @todo add-live-updates/task-6) en roep flushBatch($queue, $permissionHandler) aan vóór setBatchMode(false). Tot die tijd is de huidige half-werkende staat erger dan helemaal geen batch mode — overweeg om de setBatchMode-calls tijdelijk te verwijderen totdat de flush er ook is, zodat imports in elk geval per-object pushes blijven sturen (zoals de PR-description voor de niet-geoptimaliseerde fallback al aanneemt op regel 389).


🟡 Cross-tenant fan-out — graag expliciete design-bevestiging

PermissionHandler::getReadableByUsers() (lines 1488–1578) doet het schema-lookup met _rbac: false, _multitenancy: false. Het code-comment legt netjes uit waarom (system-internal, anders no-op bij cross-tenant events — issue #1454). Maar: na het schema-lookup wordt $this->groupManager->get($groupId)->getUsers() aangeroepen, en dat retourneert globale Nextcloud-groep-leden zonder tenancy-filter.

Concreet scenario: tenant T1 maakt object aan, schema's authorization.read bevat groupX. groupX heeft leden in zowel T1 als T2. Iedereen in beide tenants krijgt een push. Dat is mogelijk gewenst (NC-groepen zijn globaal, schema-auteur koos groupX), maar het is een securityrelevante keuze die niet expliciet besproken wordt in design.md of de spec delta. Graag bevestigen of dit gedrag intentioneel is, of dat de fan-out per tenant moet filteren wanneer het object aan een specifieke tenant toebehoort.


🟡 Object-level RBAC niet meegenomen in fan-out (UX risico)

getReadableByUsers() baseert zich uitsluitend op schema-level authorization (resolveAuthorization($schema)) + object owner. Per-object ACL's (bijv. een object met meer restrictieve permissies dan zijn schema) worden niet gecheckt.

Gevolg: een gebruiker in groupX krijgt een push voor elk object van dat schema, ook objects die getReadableByUsers voor REST 403 geeft. De push-payload zelf bevat geen content (alleen action/register/schema/uuid/version) dus geen data-leak, maar de client-side refetch via REST faalt en kan een spurious 403-toast of cache-error tonen. Niet kritiek, wel een UX-finding die documentatie verdient — of een aanvulling op getReadableByUsers die per-object permissions consulteert.


🟡 Supply chain — icewind1991/notify_push:latest

docker-compose.yml:36:

notify_push:
  image: icewind1991/notify_push:latest

latest is een moving tag → reproducibility en supply-chain auditability lijden. Pin naar een concrete release tag (b.v. icewind1991/notify_push:0.6.4 of de actuele stable). redis:7-alpine is alleen op major gepind — voor strikte reproducibility ook 7.2.4-alpine o.i.d. (minor punt; Redis 7.x patch-versies zijn meestal stabiel).

De DATABASE_URL=postgres://nextcloud:!ChangeMe!@db/nextcloud op regel 53 is duidelijk een placeholder, maar zou ik documenteren in een NOTE-comment direct boven de env-block ("Replace !ChangeMe! before any non-dev usage").


Kleinere observaties

  • Geen per-request cache op getReadableByUsers()handle() roept het per event aan, en bij snelle opeenvolgende events op hetzelfde schema wordt de groep→user fan-out elke keer opnieuw uitgevoerd. Per-request dedup op (uuid|action) voorkomt al duplicate work voor hetzelfde object, maar 1000 verschillende objects op één schema betekent 1000 group-lookups. Geen blocker (de notify_push queue is asynchroon), maar overweeg een per-request memoization op schemaId → userIds.
  • resolveRegisterSlug / resolveSchemaSlug gebruiken ook _rbac:false, _multitenancy:false — zelfde rechtvaardiging als bij getReadableByUsers, prima.
  • setBatchMode(false) en resetStaticState() schoonmaakgedrag is consistent (clears $batchedCollections; resetStaticState ook $seen + $queueUnavailable). Goed voor test-isolatie.
  • Listener registratie unconditional in Application.php — correct: constructor heeft geen IQueue dependency, runtime-resolve via getServerContainer()->get() met try/catch. Soft-fail klopt.
  • Admin status detection (3 staten) in OpenRegisterAdmin — controleert IAppManager::isInstalled + IAppConfig flag push_available die de listener zet op eerste succesvolle push. Eenvoudig en robuust.

Verdict

Blocker: ImportService batch-mode bug fixen (één van: (a) flushBatch wiring afmaken, of (b) tijdelijk de setBatchMode-wrap weghalen zodat bulk imports per-object events blijven sturen).

Voor merge graag bevestigen: cross-tenant fan-out design-keuze (geïntentioneerd of bug?).

Niet-blocker maar fix-before-merge nice-to-have: notify_push image tag pinnen.

De overige bevindingen (object-RBAC, perf cache, redis pin) zijn opvolgers en hoeven deze PR niet te blokkeren.

@MWest2020
Copy link
Copy Markdown
Member

Review — feat: live updates via notify_push (REST transport for realtime-updates)

Verdict: 🔴 REQUEST_CHANGES — 2 blokkerende bevindingen: (1) batch-import stuurt na merge géén enkele collection-push — de @todo-comment erkent dat flushBatch niet aangeroepen wordt, maar de PR-body beweert het tegenovergestelde; (2) een nieuwe phpcs-fout op regel 1053 van PermissionHandler.php is in deze PR geïntroduceerd en maakt CI rood. Geen beveiligingsrisico in de push-fan-out: payload bevat enkel slug+uuid+version, geen objectinhoud; per-object events worden correct per geautoriseerde gebruiker verstuurd.

Wat goed gaat

Uitstekend ontwerp: event-driven zonder nieuwe HTTP-endpoints, soft-fail via lazy container-resolve, correcte per-user fan-out via getReadableByUsers, batch-dedup met statische accumulator, en goed gedocumenteerde v1-beperkingen (open/public schema = geen push). De RBAC-bypass in getReadableByUsers is goed gemotiveerd en correct gescoopt — enkel voor schema-metadata, nooit voor objectinhoud. Het payloadontwerp (action + slugs + uuid + version, géén objectvelden) volgt de NC notify_push-conventie van Deck/Calendar nauwkeurig. 9 listener-scenario's + 5 constants-tests + E2E-test zijn solide coverage.

Findings

1. 🔴 Blocker — batch-import stuurt géén collection-event: setBatchMode(true) zonder flushBatch() — lib/Service/ImportService.php:775

ImportService roept NotifyPushListener::setBatchMode(true) aan (regels 775 en 965) maar nooit flushBatch(). De finally-blokken roepen alleen setBatchMode(false) aan — waarmee batch-mode wordt uitgeschakeld en $batchedCollections leeggemaakt, maar zonder dat de geaccumuleerde collection-events worden verstuurd. Resultaat: een bulk-import van 1000 rijen stuurt nul pushes.

De PR-body zegt letterlijk "ImportService — wraps bulk-save in setBatchMode(true) / flushBatch", en de docs tonen het patroon met flushBatch($queue, $permissionHandler). Beide beschrijven de geïntendeerde staat, niet de geïmplementeerde staat. De eigen @todo-comment legt de root cause uit: // Cannot call flushBatch here without IQueue.

Impact: Gebruikers van procest/decidesk/pipelinq krijgen na een bulk-import géén realtime invalidation van hun collection-views — het centrale use-case dat in de PR-body als feature beschreven staat.

Suggested fix: Injecteer ContainerInterface in ImportService en resolve IQueue lazily (identiek aan NotifyPushListener::resolveQueue()), injecteer PermissionHandler, en vervang setBatchMode(false) in de finally door flushBatch($queue, $this->permissionHandler); setBatchMode(false). Verwijder de twee @todo-comments zodra geïmplementeerd. Voeg een integratie-test toe die verifieert dat precies één collection-event verstuurd wordt na een batch.

2. 🔴 Blocker — phpcs-fout in nieuwe code PermissionHandler.php:1053 — CI rood door deze PR — lib/Service/Object/PermissionHandler.php:1053

CI-job quality / PHP Quality (phpcs) faalt op lib/Service/Object/PermissionHandler.php:1053 met End comment for long condition not found; expected //end. Deze fout bevindt zich in de nieuwe getReadableByUsers()-methode (regels 1022–1112).

PR-body stelt "PHPCS clean across all 745 files" — CI-log toont het tegendeel. De controller-fouten elders in de log zijn pre-existing op development, maar de PermissionHandler-fout op line 1053 is door deze PR geïntroduceerd.

Impact: Branch-protection vereist groene phpcs check. Daarnaast: claim-nauwkeurigheid op een security/RBAC-PR is load-bearing voor audit trails.

Suggested fix: Voeg }//end catch toe op het sluitende accolade van het } catch (\Throwable $e) {-blok in getReadableByUsers. Controleer ook de foreach op line 1073 en de multiline if op lines 1085-1088 op ontbrekende //end foreach en //end if commentaar volgens de phpcs-conventie van dit project.

3. 🟡 Concern — Statische $seen-array accumuleert over requests in PHP-FPM workers — lib/Listener/NotifyPushListener.php:78

private static array $seen wordt gedocumenteerd als "per-request deduplicatie", maar PHP-FPM workers hergebruiken processen over meerdere HTTP-requests. Static class properties persisteren voor de gehele worker-lifetime. resetStaticState() is uitdrukkelijk test-only.

Impact: Na voldoende requests accumuleert $seen duizenden {uuid}|{action}-sleutels. In theorie kan een push voor een UUID die eerder in dezelfde worker behandeld werd — bij een volgende, onafhankelijke request — onterecht als duplicaat gesuppresseerd worden.

Suggested fix: Implementeer een BeforeHandleEvent- of shutdown-handler die self::$seen = [] reset per request, of vervang de static array door een instance-property.

4. 🟡 Concern — Cross-tenant inconsistentie: schema-lookup bypasst RBAC, register-lookup niet — lib/Service/Object/PermissionHandler.php:1037

getReadableByUsers() fetcht het schema met _rbac: false, _multitenancy: false. Maar resolveAuthorization() valt terug op getRegisterForSchema() wanneer het schema géén eigen authorization-blok heeft — en getRegisterForSchema() roept registerMapper->find($registerId) aan zonder RBAC-bypass.

Impact: Voor cross-tenant events met schema-zonder-eigen-auth + register-met-auth (meest voorkomende multi-tenant configuratie) faalt de register-lookup → resolveAuthorization() returnt null → listener stuurt nul pushes ook al zouden er geautoriseerde gebruikers zijn.

Suggested fix: Geef getRegisterForSchema() een bool $bypassRbac = false-parameter, en roep het aan met bypassRbac: true vanuit getReadableByUsers().

5. 🟡 Concern — flushBatch() broadcast zonder 'user'-filter — alle clients ontvangen ongeacht rechten — lib/Listener/NotifyPushListener.php:367

flushBatch() pusht de collection-event zonder user-veld: ['message' => $collectionChannel, 'body' => $payload]. Per notify_push-conventie stuurt een push zonder user naar alle verbonden clients die geabonneerd zijn op dat kanaal, ongeacht RBAC.

Het argument in de comment ("Clients that don't have access simply ignore the event when they refetch") is een client-side trust assumption. De payload bevat {action: 'batch', register: registerSlug, schema: schemaSlug} — dat is schema-existentie-informatie die niet altijd openbaar is.

Impact: Een gebruiker zonder read-rechten leert dat er iets veranderd is in een specifiek schema. Inconsistent met dispatchPushes() die wél per-user met permission-guard stuurt.

Suggested fix: Gebruik in flushBatch() dezelfde permission-scoping als dispatchPushes(): resolve de reader-set via de meegegeven PermissionHandler $permHandler parameter (die nu nergens wordt gebruikt) en stuur per geautoriseerde gebruiker.

6. 🟡 Concern — $queueUnavailable=true is permanent voor worker-lifetime — lib/Listener/NotifyPushListener.php:380

Wanneer IQueue niet opgeroepen kan worden zet resolveQueue() self::$queueUnavailable = true. Alle volgende requests in dezelfde PHP-FPM worker slaan de queue-resolve permanent over. resetStaticState() is test-only.

Impact: Als notify_push tijdelijk down gaat en daarna weer beschikbaar is, blijven bestaande workers queueUnavailable=true houden tot ze gerecycleerd worden (via pm.max_requests of handmatig). Productie-installaties met lange worker-lifetimes kunnen meerdere uren push-uitval krijgen zonder log-indicatie.

Suggested fix: Vervang de permanent-static benadering met TTL-strategie: sla naast de vlag een timestamp op ($queueUnavailableSince) en hercheck na b.v. 60 seconden.

7. 🟢 Minor — PHPUnit-job staat als 'skipped' — testclaims enkel lokaal geverifieerd — tests/Unit/Listener/NotifyPushListenerTest.php:1

De CI-job quality / PHPUnit (PHP ${{ matrix.php-version }}) staat als skipped — pre-existing CI-configuratiefout (matrix-variable substitutie faalt). PR-body claimt "11,860 unit tests pass" — lokale claim, niet CI-geverifieerd.

Impact: De drie nieuwe testbestanden zijn niet door CI gerund. Regressies in de refactoring van ObjectServiceTest.php (5510 gewijzigde regels) zijn onzichtbaar.

Suggested fix: Fix de matrix-variabele in .github/workflows/code-quality.yml als los issue. Deel lokale testuitvoer in de PR-discussie.

8. 🟢 Minor — ESLint + license (dompdf) CI-failures zijn pre-existing

4 ESLint-fouten en de dompdf LGPL-2.1 license-failure zijn beide aanwezig op development vóór deze PR. Geen van de ESLint-fouten zit in bestanden die deze PR wijzigt.

Impact: Niet door deze PR geïntroduceerd.

Suggested fix: Registreer dompdf/dompdf in .license-overrides.json als aparte taak. ESLint-fouten als apart issue op development.


Geconsolideerde review via /review-pr — Thorough mode.

Copy link
Copy Markdown
Member

@MWest2020 MWest2020 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zie consolidated review comment voor complimenten, findings en suggested fixes.

* @spec openspec/changes/add-live-updates/tasks.md#task-4
*/
public static function flushBatch(object $queue, PermissionHandler $permHandler): void
{
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] flushBatch() broadcasts to ALL connected clients — RBAC bypass

flushBatch() accepts a $permHandler parameter but never calls it. Instead it emits IQueue::push('notify_custom', ['message' => ..., 'body' => ...]) with no user field, broadcasting to every connected client regardless of whether they have read access to the affected collection. This is a direct RBAC bypass on the batch-import push path. Either use $permHandler to resolve the authorised reader list and fan out per-user like dispatchPushes() does, or drop the batch collection push entirely until a safe broadcast mechanism is available.

_multitenancy: $_multitenancy,
validation: $validation,
events: $events,
enrich: $enrich
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] Batch mode activated but flushBatch() is never called — events silently swallowed

processSpreadsheetBatch() calls NotifyPushListener::setBatchMode(true) then clears it with setBatchMode(false) in the finally block, but never calls flushBatch(). All push events during bulk imports are silently eaten. The batch mode flag also bleeds across calls: if setBatchMode(true) is set and an exception aborts the import before setBatchMode(false), subsequent saves in the same request will be silently suppressed. Both the missing flush and the bleed risk need fixing before merge.

* @return array<string> Deduplicated list of user IDs, or empty array when the
* object is publicly readable (no restriction) or the schema
* cannot be resolved.
*
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] getReadableByUsers() returns [] for schemas with 'admin' group — admins never receive push events

When the schema's read authorization contains the admin group identifier, getReadableByUsers() short-circuits and returns an empty array. This means administrators configured as readers of a schema receive no live-update events. The admin group has known membership resolvable via IGroupManager; treating it as 'broadcast' and silently dropping the push is wrong. Either resolve admin group members via IGroupManager::get('admin') or document that admin-gated schemas are explicitly excluded — but that must be surfaced as a UI warning, not a silent drop.

Comment thread docker-compose.yml
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] Redis port 6379 bound to 0.0.0.0 with no authentication

The new redis service exposes port 6379:6379 on all host interfaces with no requirepass, no --bind 127.0.0.1. Any process reachable on the host network can read and write the Redis instance, including Nextcloud session data and the notify_push channel state. At minimum add command: redis-server --requirepass ChangeMe and align the REDIS_URL in the notify_push service.

* @spec openspec/changes/add-live-updates/tasks.md#task-4
*/
private function dispatchPushes(string $action, ObjectEntity $object, object $queue): void
{
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] dispatchPushes() sends zero pushes when getReadableByUsers() returns [] — open/public schemas get no events

When PermissionHandler::getReadableByUsers() returns [] (open schema or no authorization configured), dispatchPushes() emits nothing — including no DEBUG log. The push_available flag is never set for open-schema instances, leaving the admin UI permanently stuck on 'unreachable'. For a government register that is publicly readable this means the entire notify_push feature is dead on arrival. There is no fallback, no warning, and no test for this path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] E2E test asserts wrong payload key — will always silently pass with empty data

The test reads $pushedPayloads[0]['payload']['data'] ?? '{}' and json-decodes it, then asserts action, uuid, register, schema. But dispatchPushes() pushes with ['user' => ..., 'message' => ..., 'body' => $payload] — the key is body, not data. Because ?? '{}' provides a fallback, all assertions receive null and silently pass. The test provides false confidence. Fix: change 'data' to 'body' and remove the json_encode/decode round-trip.

* @spec openspec/changes/add-live-updates/tasks.md#task-4
*/
private function dispatchPushes(string $action, ObjectEntity $object, object $queue): void
{
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] No rate limiting on push events — synchronous N×IQueue::push on every object save

dispatchPushes() calls IQueue::push() N times (N = number of authorised readers) synchronously on every object lifecycle event. For a schema with 200 readers, every single save call blocks for 200 IPC/Redis calls with no queue offloading, no rate limit, and no circuit breaker.

}//end __construct()

/**
* Handle an object lifecycle event.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] Static $seen dedup accumulator not reset at end of request — risk in persistent PHP runtimes

$seen is a static property used for per-request deduplication. In persistent PHP workers (Roadrunner, Swoole, FrankenPHP) this accumulates across requests and silently suppresses legitimate pushes after the first occurrence of any (uuid, action) pair. The resetStaticState() method is marked @internal For use in unit tests only. Either clear $seen via a request-lifecycle hook or explicitly document that persistent PHP runtimes are unsupported.

// silently no-ops with an empty reader list — no push fires. Issue #1454.
$schema = $this->schemaMapper->find(
id: $schemaId,
_rbac: false,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] getReadableByUsers() resolves schema bypassing RBAC/_multitenancy — cross-tenant data exposure risk

SchemaMapper::find() is called with _rbac: false, _multitenancy: false. In a multitenant deployment this means the push logic can resolve schemas and slugs across tenant boundaries. If schema UUIDs are guessable or shared across tenants, a cross-tenant event could leak the register/schema slug to users in the wrong tenant.

*
* @spec openspec/changes/add-live-updates/tasks.md#task-4
*/
private function dispatchPushes(string $action, ObjectEntity $object, object $queue): void
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] Missing test: unauthorized user MUST NOT receive push — spec requirement without coverage

The realtime-updates spec states: 'GIVEN user external-1 does NOT have read access WHEN the object is updated THEN IQueue::push() MUST NOT be called with userId = external-1'. No unit test covers this scenario. This is the most important security property of the fan-out logic and it has no test coverage.

* @spec openspec/changes/add-live-updates/tasks.md#task-4
*/
public static function flushBatch(object $queue, PermissionHandler $permHandler): void
{
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] $permHandler parameter in flushBatch() is dead code — masks intent

flushBatch(object $queue, PermissionHandler $permHandler) declares $permHandler but the parameter is referenced nowhere in the body. Remove the parameter if broadcast is truly intended, or implement per-user fan-out using it.

### Task 14 — Run `composer check:strict` and fix all findings
After all PHP changes, run `composer check:strict` (PHPCS, PHPMD, Psalm, PHPStan). Fix every finding before submitting — including any pre-existing issues encountered in files this change touches (per project convention: don't leave them for later).

### Task 15 — Run unit tests and confirm no regressions
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] tasks.md specifies 'userId'/'data' payload keys but implementation uses 'user'/'message'/'body'

Task 4 in tasks.md specifies ['userId' => $userId, 'data' => json_encode($payload)]. The actual implementation uses ['user' => $userId, 'message' => $objectChannel, 'body' => $payload]. This inconsistency caused the E2E test assertion bug. Update tasks.md and the spec delta to match the canonical notify_push format.

* @return void
*/
public function testConditionalRulesEvaluatePerObjectUuid(): void
{
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] Cache-bypass behavior change for match-rule schemas not reflected in PermissionHandler source diff

The test was updated to expect 4 delegations (from 2) with comment 'Schemas with match clauses bypass the cache entirely — security fix'. However, PermissionHandler.php diff contains no source change that implements this cache-bypass logic. Either the source change is missing from this PR, or the test is asserting against behavior that doesn't exist yet.

Comment thread docker-compose.yml
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] notify_push port 7867 exposed on all interfaces — unauthenticated WebSocket endpoint

The notify_push service binds port 7867:7867 on all host interfaces. Consider binding to 127.0.0.1:7867:7867 or restricting access via the Docker network.

Copy link
Copy Markdown
Contributor

@WilcoLouwerse WilcoLouwerse left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

🔴 Blockers (6)

🟡 Concerns (8)

🟢 Minor (5)

  • Author email inconsistency: dev@conductio.nl (typo: conductio vs conduction) (lib/Listener/NotifyPushListener.php:1)
    The file-level docblock uses dev@conductio.nl — note conductio.nl vs the correct domain conduction.nl. Standardise to info@conduction.nl or dev@conduction.nl across all new files.
  • resolveRegisterSlug / resolveSchemaSlug duplicate the same bypass pattern — extract helper (lib/Listener/NotifyPushListener.php:219)
    resolveRegisterSlug() and resolveSchemaSlug() are near-identical: both catch \Throwable and return null, both call find(id: ..., _rbac: false, _multitenancy: false) with identical inline comments. Extract a shared helper to keep the bypass justification comment in one place.
  • PushEvents is a pure constants class — should be declared final (lib/Push/PushEvents.php:1)
    PushEvents contains only constants and is not designed for extension. Add final class PushEvents to prevent accidental subclassing.
  • en.json missing trailing newline (l10n/en.json:549)
    The diff shows \ No newline at end of file for both l10n/en.json and l10n/nl.json. Many editors and POSIX tools expect a trailing newline; CI linters may flag it.
  • getReadableByUsers() silently skips match-rule authorization entries without a group key (lib/Service/Object/PermissionHandler.php:1019)
    The group-extraction loop silently skips entries that are arrays without a group key. Add a $this->logger->debug(...) when an unrecognised entry shape is encountered so operators can diagnose missing push events during setup.

Reviewed by WilcoLouwerse via automated batch review.

@rubenvdlinde rubenvdlinde merged commit af07735 into development May 18, 2026
16 of 20 checks passed
@rubenvdlinde rubenvdlinde deleted the feature/add-live-updates branch May 18, 2026 11:19
@WilcoLouwerse
Copy link
Copy Markdown
Contributor

🔍 Retrospective audit findings — surfaced from #1515

While reviewing integration rollup PR #1515, three 🔴 blockers trace back to the live-updates changes in this PR. Documenting here for follow-up:

  1. NotifyPushListener::flushBatch() broadcasts to every connected client (no user scoping) — leaks register/schema slugs to listeners without read permission. Inline comment on #1515
  2. PermissionHandler::getReadableByUsers() returns [] for admin/public schemas → silently drops all pushes for admin-scoped objects. Inline comment on #1515
  3. ImportService activates batch mode but never calls flushBatch() → every per-object event during bulk imports is silently suppressed and the collection event never fires. Inline comment on #1515

The PR body of #1515 already surfaced findings 1 and 3 under "#1453 surfaced for maintainer judgement" — confirming they merged unresolved. Worth opening follow-up issues / a fixup PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-code-review Build complete — awaiting code reviewer ready-for-security-review Code review complete — awaiting security reviewer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants