Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.

feat(integration-registry): contract + registry + external router (umbrella PR 1/N)#1467

Merged
rubenvdlinde merged 2 commits into
developmentfrom
feature/1307/pluggable-integration-registry
May 18, 2026
Merged

feat(integration-registry): contract + registry + external router (umbrella PR 1/N)#1467
rubenvdlinde merged 2 commits into
developmentfrom
feature/1307/pluggable-integration-registry

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Closes part of #1307.

First slice of the pluggable-integration-registry umbrella. Lands the foundation that every leaf change (W1-W3) depends on: the provider contract, the registry, external-routing plumbing, and the query-time storage-strategy helper. 6 of 69 umbrella tasks complete (Backend — Provider Contract & Registry section).

What's in

File Role
lib/Service/Integration/IntegrationProvider.php 15-method interface per design.md normative contract
lib/Service/Integration/AbstractIntegrationProvider.php Base class — group=null, requiresPermission=null, auth=['type'=>'none'], no OpenConnector source, get/create/update/delete default to NotImplementedException
lib/Service/Integration/IntegrationRegistry.php Explicit addProvider() registration with collision detection (AD-13) + external-source rejection (AD-4). list() / listIds() / get() / getEnabled(); withProviders() test seam
lib/Service/Integration/ExternalIntegrationRouter.php Dispatches storage='external' CRUD through OpenConnector. Surfaces failures via ProviderUnavailableException with 3-way cause classification per AD-23. Includes probe() for admin/OCS health
lib/Service/Integration/QueryTimeContract.php Codifies the 2 s render timeout (AD-22) + HTTP 501 envelope builder for ObjectsController to consume in tasks 7-11
lib/Exception/NotImplementedException.php Thrown by query-time / list-only providers' unsupported CRUD
lib/Exception/ProviderUnavailableException.php Carries the 3-way cause (openconnector-down / openconnector-source-missing / upstream-service-down) + getDetails()
lib/AppInfo/Application.php New registerIntegrationRegistry() phase wires both as shared per-request singletons
tests/Unit/Service/Integration/*Test.php 25 unit tests across 4 classes (registry, abstract provider, external router, query-time contract)

Spec deviation flagged

Tasks.md said "DI-tag-based discovery" but modern Nextcloud doesn't expose IAppContainer::queryAll(<tag>) as a public API. Switched to explicit addProvider() at app bootstrap — identical semantics, NC-compatible. Documented in:

  • IntegrationRegistry.php class docblock
  • openspec/changes/pluggable-integration-registry/tasks.md (inline note on the checked task)
  • openspec/changes/pluggable-integration-registry/plan.json (status: done, description annotated)

What's still pending in the umbrella

  • Schema validator refactor (tasks 7-11): Schema::validateLinkedTypesValue consumes IntegrationRegistry::listIds; deprecate VALID_LINKED_TYPES + LinkedEntityService::TYPE_COLUMN_MAP
  • 5 Built-in providers (tasks 12-17): files/notes/tasks/tags/audit-trail wrappers
  • Routes, controller, capabilities (tasks 18-22): IntegrationsController, ObjectsController sub-resource dispatch, OCS capabilities block
  • Admin UI + frontend registry + 3 widgets + Cn refactor* (tasks 23-46)
  • Tests / CI parity gate / scaffold / ADR / translations (tasks 47-67)
  • E2E acceptance (tasks 68-71)

Each follow-up PR will reference the same issue #1307 and tick its plan.json + tasks.md as it goes.

Test plan

  • All new files pass php -l syntax check
  • vendor/bin/phpunit -c phpunit-unit.xml tests/Unit/Service/Integration/25 tests, 42 assertions, all pass (run inside the openregister-postgres dev container)
  • composer check:strict — deferred to PR 2 once the strict-quality gates are wired against the new lib/Service/Integration/ subdir
  • Frontend changes — none in this PR; first frontend touchpoint lands in PR 3 of the umbrella

🤖 Generated with Claude Code

… external router (umbrella PR 1/N)

First slice of the pluggable-integration-registry umbrella (#1307).
Lands the foundation that every leaf change in waves W1-W3 depends on:
the provider contract, the registry, external-routing plumbing, and
the query-time storage-strategy helper. No built-in provider
migration yet (tasks 12-17); no schema validator refactor yet (tasks
7-11); no frontend wiring yet (tasks 25+).

Tasks completed: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6 (6/69 of the umbrella).

What this adds:

- lib/Service/Integration/IntegrationProvider.php — 15-method
  interface per design.md normative contract. Storage strategies:
  magic-column / link-table / external / query-time.
- lib/Service/Integration/AbstractIntegrationProvider.php — base
  class with sensible defaults; mutation methods (get / create /
  update / delete) default to NotImplementedException so list-only
  and query-time providers don't have to spell that out.
- lib/Service/Integration/IntegrationRegistry.php — explicit
  addProvider() registration with collision detection (AD-13) and
  external-source rejection (AD-4). Spec called for DI-tag-based
  discovery, but modern Nextcloud doesn't expose
  IAppContainer::queryAll(<tag>) as public API. Switched to explicit
  addProvider() at app bootstrap with identical semantics —
  documented in the file's class docblock + in tasks.md.
- lib/Service/Integration/ExternalIntegrationRouter.php — dispatches
  storage='external' CRUD calls through OpenConnector. Surfaces
  failures via ProviderUnavailableException with a 3-way cause
  classification (openconnector-down / openconnector-source-missing
  / upstream-service-down) per AD-23. Includes probe() for admin
  health reporting.
- lib/Service/Integration/QueryTimeContract.php — codifies the 2 s
  render timeout (AD-22) and the HTTP 501 envelope builder for the
  ObjectsController to consume when tasks 7-11 refactor it.
- lib/Exception/NotImplementedException.php — thrown by query-time /
  list-only providers' unsupported CRUD methods.
- lib/Exception/ProviderUnavailableException.php — carries the
  3-way cause classification + a getDetails() helper that produces
  the `{cause: …}` payload the UI renders.
- lib/AppInfo/Application.php — new private
  registerIntegrationRegistry() phase wires the registry and the
  router as shared per-request singletons.
- tests/Unit/Service/Integration/*Test.php — 25 unit tests across 4
  classes covering addProvider() validation (collision + external
  source), getEnabled() filtering, AbstractIntegrationProvider's
  NotImplementedException defaults, ExternalIntegrationRouter's
  failure-mode classification, and QueryTimeContract's HTTP envelope
  shape. All green.

What's still pending (rest of the umbrella):

- Schema validator refactor (tasks 7-11): Schema::validateLinkedTypesValue
  consumes IntegrationRegistry::listIds; deprecation of
  VALID_LINKED_TYPES + LinkedEntityService::TYPE_COLUMN_MAP.
- Built-in providers (tasks 12-17): 5 BuiltinProviders/*Provider.php
  classes wrapping the existing files/notes/tasks/tags/audit-trail
  integrations.
- Routes + controller + capabilities (tasks 18-22): IntegrationsController,
  ObjectsController sub-resource dispatch via registry, OCS
  capabilities block.
- Admin UI + frontend registry + 3 missing widgets + CnObjectSidebar /
  CnDashboardPage / CnDetailPage / CnFormDialog / CnDetailGrid refactor
  (tasks 23-46).
- Tests / CI parity gate / scaffold script / ADR + docs / translations
  (tasks 47-67).
- E2E acceptance verification (tasks 68-71).

Spec adjustment: tasks.md and plan.json record the DI-tag → explicit
addProvider() pivot explicitly so the next session (or hydra builder)
doesn't trip on it.

Refs: #1307
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 34d7f32

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-11 12:07 UTC

Download the full PDF report from the workflow artifacts.

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] No caller-authorization check before dispatching to provider (IDOR class)

ExternalIntegrationRouter::call() receives a provider and immediately dispatches an HTTP call through OpenConnector. There is zero check that the calling user is allowed to invoke this provider: no requiresPermission() enforcement, no tenant-scope guard, no object-ownership verification. The IntegrationProvider interface declares requiresPermission(): ?string and the docblock states 'the registry calls AuthorizationService with the current user and target object to gate visibility (AD-16)' — but that call is never made. Fix: ExternalIntegrationRouter::call() must accept an IUser (or string $userId) and enforce requiresPermission() via AuthorizationService before invoking.

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] Gate 7 — silent null from get() collapses auth decision

IntegrationRegistry::get() returns null for an unknown id. Any consumer that calls get($id) to decide whether to proceed will silently skip the dispatch when the registry is misconfigured. No distinction between 'provider does not exist on this instance' (valid no-op) and 'provider exists but failed to register' (should surface an error). Fix: distinguish the two null cases (introduce a ProviderNotFoundException or return a typed result object).

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] Gate 3 — abstract base ships NotImplementedException stubs as trapdoors for CRUD operations

AbstractIntegrationProvider implements get(), create(), update(), delete() as throw-by-default stubs. Every concrete provider that forgets to override a mutation method will silently inherit a NotImplementedException-throwing body. A provider declaring storage='link-table' but forgetting to override create() will throw at runtime — translated to HTTP 501, which controllers may interpret as 'query-time provider'. Remove default implementations; keep them in a separate AbstractQueryTimeProvider sub-class so non-query-time providers must implement them.

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] No caller-authorization check before dispatching to provider (IDOR class)

ExternalIntegrationRouter::call() receives a provider and immediately dispatches an HTTP call through OpenConnector. There is zero check that the calling user is allowed to invoke this provider: no requiresPermission() enforcement, no tenant-scope guard, no object-ownership verification. The IntegrationProvider interface declares requiresPermission(): ?string and the docblock states 'the registry calls AuthorizationService with the current user and target object to gate visibility (AD-16)' — but that call is never made. Fix: ExternalIntegrationRouter::call() must accept an IUser (or string $userId) and enforce requiresPermission() via AuthorizationService before invoking.

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] Gate 7 — silent null from get() collapses auth decision

IntegrationRegistry::get() returns null for an unknown id. Any consumer that calls get($id) to decide whether to proceed will silently skip the dispatch when the registry is misconfigured. No distinction between 'provider does not exist on this instance' (valid no-op) and 'provider exists but failed to register' (should surface an error). Fix: distinguish the two null cases (introduce a ProviderNotFoundException or return a typed result object).

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] Gate 3 — abstract base ships NotImplementedException stubs as trapdoors for CRUD operations

AbstractIntegrationProvider implements get(), create(), update(), delete() as throw-by-default stubs. Every concrete provider that forgets to override a mutation method will silently inherit a NotImplementedException-throwing body. A provider declaring storage='link-table' but forgetting to override create() will throw at runtime — translated to HTTP 501, which controllers may interpret as 'query-time provider'. Remove default implementations; keep them in a separate AbstractQueryTimeProvider sub-class so non-query-time providers must implement them.

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] Gate 7 — broad Throwable catch in loadSource() reclassifies all exceptions as source-missing

loadSource() catches \Throwable (line 691) and unconditionally rethrows as ProviderUnavailableException with cause CAUSE_OPENCONNECTOR_SOURCE_MISSING. A DI container resolution failure, DB connection error, or OOM during SourceMapper::find() is reported as 'OpenConnector source missing — Reconfigure connector'. Callers catching ProviderUnavailableException will silently treat infrastructure failures as configuration problems. Only catch specific mapper-not-found exceptions; let infrastructure failures propagate.

@@ -0,0 +1,266 @@
<?php

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] Gate 1 — SPDX machine-readable identifiers missing from all 6 new lib/ files

All 6 new lib/ files (IntegrationProvider.php, AbstractIntegrationProvider.php, ExternalIntegrationRouter.php, IntegrationRegistry.php, QueryTimeContract.php, NotImplementedException.php, ProviderUnavailableException.php) contain only the human-readable @license PHPDoc tag, not the SPDX identifiers. CI License composer check is already failing. Add SPDX-License-Identifier: EUPL-1.2 and SPDX-FileCopyrightText: 2026 Conduction B.V. lines.

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] withProviders() is public on the shared singleton

withProviders() is declared public on the per-request singleton. Any code calling IntegrationRegistry::get()->withProviders([]) will destroy every registered provider for the rest of the request. The docblock says 'Production code SHOULD use addProvider()' — non-binding. An attacker-controlled provider reaching the DI container can flush all other providers. Make withProviders() @internal, throw LogicException unless defined('PHPUNIT_RUN'), or expose only via a test-seam interface not bound in production DI.

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] getStorageStrategy() returns unvalidated string — typo-prone

getStorageStrategy() is typed string with the valid set documented only in a docblock. A typo like 'link_table', 'external ', 'External' passes registry validation and fails only at dispatch time deep inside assertProviderIsExternal() with a misleading \LogicException. Introduce a StorageStrategy enum (PHP 8.1) or const STORAGE_* constants on the interface, validated in addProvider().

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 test for provider registration order or duplicate-id determinism

Multiple apps call addProvider() from Application::register() hooks during bootstrap. PHP is single-threaded per request but bootstrap order is non-deterministic across NC versions. Duplicate-id 'first wins' combined with unordered bootstrap means the winner varies between deployments. No test asserts bootstrap-order determinism. Add such a test and document priority ordering in addProvider().

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] isEnabledForUser('openconnector') called without a user — result cached per-request

isOpenConnectorAvailable() calls appManager->isEnabledForUser('openconnector') without a user parameter and caches the result. NC's isEnabledForUser() without second arg checks global state, not per-user. For cli/batch/system jobs spanning multiple users the result may be wrong for some. Accept ?IUser in the constructor or document instance-level scope only.

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] invoke() uses dynamic method_exists dispatch against an untrusted service

invoke() resolves OpenConnector's CallService from DI then checks method_exists for call and request. Any object registered under that DI key is accepted without type verification. A different version registering a different CallService (e.g. void-returning, different exception hierarchy) is silently invoked. Declare a local ICallService interface, verify implementation, throw ProviderUnavailableException(CAUSE_OPENCONNECTOR_DOWN) if mismatched.

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] QueryTimeContract timeout is advisory only — no enforcement

RENDER_TIMEOUT_SECONDS = 2.0 defines the timeout but no enforcement mechanism exists: no pcntl_alarm, no set_time_limit, no wrapper measuring elapsed time. The constant is advisory; tests only assert the value, not enforcement. Downstream consumers may skip wrapping. Add a static helper QueryTimeContract::wrapWithTimeout(callable $fn): array that enforces the 2s limit so consumers have a ready-made enforcer.

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 happy-path test for the router — upstream call path completely untested

The test file states 'actual upstream call path is exercised in integration tests' but no such integration tests exist in this PR. All 4 tests only verify failure modes. invoke() and decodeResponse() have non-trivial branching (3 response-shape branches, 2 CallService method signatures) with zero coverage. Add at least one happy-path test with a mocked ContainerInterface returning a CallService stub with call() returning an array.

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] getEnabled() iterates all providers calling isEnabled() — no caching

On instances with 20+ providers, getEnabled() becomes O(n) IAppManager calls per invocation, and is called on every API request (capabilities, schema validation). Interface docblock mentions per-request cache but registry provides none. Memoize the result within the request, or document isEnabled() MUST be O(1) (no IO) and enforce via test.

@WilcoLouwerse WilcoLouwerse left a comment

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.

Review

🔴 Blockers (6)

🟡 Concerns (7)

🟢 Minor (2)

  • Interface has 16 methods but plan.json says 15 (lib/Service/Integration/IntegrationProvider.php:839)
    Counting: getId, getLabel, getIcon, getGroup, getRequiredApp, getStorageStrategy, getOpenConnectorSource, isEnabled, requiresPermission, authRequirements, list, get, create, update, delete, health = 16. Plan.json task 1.1 says 15. Spec and implementation diverged during authoring. Verify against design.md and update task description / plan.json count.
  • IntegrationRegistry::list() shadows IntegrationProvider::list() (lib/Service/Integration/IntegrationRegistry.php:1206)
    Both interfaces declare a list() method with completely different semantics. Loosely-typed variables can call the wrong one. Rename the registry method to listProviders() or all() to disambiguate, especially since the registry's list() is 'irrespective of isEnabled()' while the provider's list() is the data-fetch operation.

Reviewed by WilcoLouwerse via automated batch review.

- SPDX-License-Identifier + SPDX-FileCopyrightText added inside the
  file docblock (per feedback_spdx-in-docblock.md, never as // lines)
  to: IntegrationProvider, IntegrationRegistry, AbstractIntegrationProvider,
  ExternalIntegrationRouter, QueryTimeContract, NotImplementedException,
  ProviderUnavailableException.
- ExternalIntegrationRouter.php:291 — 'else if' → 'elseif' (phpcs).

Review: WilcoLouwerse [BLOCKER] SPDX missing (Gate 1).
@rubenvdlinde rubenvdlinde merged commit 1cdfa5b into development May 18, 2026
17 of 20 checks passed
@rubenvdlinde rubenvdlinde deleted the feature/1307/pluggable-integration-registry branch May 18, 2026 11:19
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants