Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@
'Consumers' => ['url' => 'api/consumers'],
],
'routes' => [
// Integration registry (read-only discovery API) —
// pluggable-integration-registry task 4.3 / tasks.md#task-20.
['name' => 'integrations#index', 'url' => '/api/integrations', 'verb' => 'GET'],
['name' => 'integrations#show', 'url' => '/api/integrations/{id}', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']],

// Object-scoped integration sub-resource dispatch —
// pluggable-integration-registry task 4.2 / tasks.md#task-19.
['name' => 'objectIntegrations#index', 'url' => '/api/objects/{register}/{schema}/{id}/integrations/{integrationId}', 'verb' => 'GET', 'requirements' => ['register' => '[^/]+', 'schema' => '[^/]+', 'id' => '[^/]+', 'integrationId' => '[^/]+']],
['name' => 'objectIntegrations#show', 'url' => '/api/objects/{register}/{schema}/{id}/integrations/{integrationId}/{entityId}', 'verb' => 'GET', 'requirements' => ['register' => '[^/]+', 'schema' => '[^/]+', 'id' => '[^/]+', 'integrationId' => '[^/]+', 'entityId' => '[^/]+']],
['name' => 'objectIntegrations#create', 'url' => '/api/objects/{register}/{schema}/{id}/integrations/{integrationId}', 'verb' => 'POST', 'requirements' => ['register' => '[^/]+', 'schema' => '[^/]+', 'id' => '[^/]+', 'integrationId' => '[^/]+']],
['name' => 'objectIntegrations#update', 'url' => '/api/objects/{register}/{schema}/{id}/integrations/{integrationId}/{entityId}', 'verb' => 'PUT', 'requirements' => ['register' => '[^/]+', 'schema' => '[^/]+', 'id' => '[^/]+', 'integrationId' => '[^/]+', 'entityId' => '[^/]+']],
['name' => 'objectIntegrations#destroy', 'url' => '/api/objects/{register}/{schema}/{id}/integrations/{integrationId}/{entityId}', 'verb' => 'DELETE', 'requirements' => ['register' => '[^/]+', 'schema' => '[^/]+', 'id' => '[^/]+', 'integrationId' => '[^/]+', 'entityId' => '[^/]+']],

// PATCH routes for resources (partial updates).
['name' => 'registers#patch', 'url' => '/api/registers/{id}', 'verb' => 'PATCH', 'requirements' => ['id' => '[^/]+']],
['name' => 'schemas#patch', 'url' => '/api/schemas/{id}', 'verb' => 'PATCH', 'requirements' => ['id' => '[^/]+']],
Expand Down
47 changes: 47 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,11 @@ function () {
// clients can discover URN endpoints + the instance slug without
// probing routes.
$context->registerCapability(UrnCapability::class);

// pluggable-integration-registry task 4.5 (tasks.md#task-22):
// advertise the integration registry through the OCS
// capabilities endpoint.
$context->registerCapability(\OCA\OpenRegister\Capabilities\IntegrationsCapability::class);
}//end register()

/**
Expand Down Expand Up @@ -864,6 +869,48 @@ function (ContainerInterface $container) {

$this->registerBuiltinIntegrationProviders($context);

// IntegrationsController — read-only API over the registry.
$context->registerService(
\OCA\OpenRegister\Controller\IntegrationsController::class,
function (ContainerInterface $container) {
return new \OCA\OpenRegister\Controller\IntegrationsController(
appName: 'openregister',
request: $container->get('OCP\IRequest'),
registry: $container->get(\OCA\OpenRegister\Service\Integration\IntegrationRegistry::class),
userSession: $container->get('OCP\IUserSession'),
groupManager: $container->get('OCP\IGroupManager'),
logger: $container->get('Psr\Log\LoggerInterface')
);
}
);

// ObjectIntegrationsController — object-scoped sub-resource
// dispatch through the registry.
$context->registerService(
\OCA\OpenRegister\Controller\ObjectIntegrationsController::class,
function (ContainerInterface $container) {
return new \OCA\OpenRegister\Controller\ObjectIntegrationsController(
appName: 'openregister',
request: $container->get('OCP\IRequest'),
registry: $container->get(\OCA\OpenRegister\Service\Integration\IntegrationRegistry::class),
logger: $container->get('Psr\Log\LoggerInterface')
);
}
);

// IntegrationsCapability — surfaces the registry through the
// Nextcloud OCS capabilities endpoint, role-redacted per AD-17.
$context->registerService(
\OCA\OpenRegister\Capabilities\IntegrationsCapability::class,
function (ContainerInterface $container) {
return new \OCA\OpenRegister\Capabilities\IntegrationsCapability(
registry: $container->get(\OCA\OpenRegister\Service\Integration\IntegrationRegistry::class),
userSession: $container->get('OCP\IUserSession'),
groupManager: $container->get('OCP\IGroupManager')
);
}
);

}//end registerIntegrationRegistry()

/**
Expand Down
139 changes: 139 additions & 0 deletions lib/Capabilities/IntegrationsCapability.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

/**
* IntegrationsCapability — surface the integration registry through
* Nextcloud's `/ocs/v2.php/cloud/capabilities` endpoint.
*
* Per AD-17 the capabilities block is role-redacted: every
* authenticated user gets the public surface (id, label, group,
* enabled, surfaces); admins additionally receive operational
* fields (requiresPermission, authStatus, openConnectorSource).
* Absence of an admin field for a non-admin caller is
* indistinguishable from "not configured".
*
* SPDX-License-Identifier: EUPL-1.2
* SPDX-FileCopyrightText: 2026 Conduction B.V.
*
* @category Capabilities
* @package OCA\OpenRegister\Capabilities
*
* @author Conduction Development Team <info@conduction.nl>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* @link https://conduction.nl
*
* @spec openspec/changes/pluggable-integration-registry/tasks.md#task-21
*/

declare(strict_types=1);

namespace OCA\OpenRegister\Capabilities;

use OCA\OpenRegister\Service\Integration\IntegrationProvider;
use OCA\OpenRegister\Service\Integration\IntegrationRegistry;
use OCP\Capabilities\ICapability;
use OCP\IGroupManager;
use OCP\IUserSession;

/**
* OCS capability provider for the integration registry.
*/
class IntegrationsCapability implements ICapability
{

/**
* Constructor.
*
* @param IntegrationRegistry $registry Integration registry.
* @param IUserSession $userSession Current user session.
* @param IGroupManager $groupManager Group manager (admin check).
*
* @return void
*/
public function __construct(
private IntegrationRegistry $registry,
private IUserSession $userSession,
private IGroupManager $groupManager,
) {
}//end __construct()

/**
* @inheritDoc
*
* @return array<string,mixed>
*/
public function getCapabilities(): array
{
$isAdmin = $this->currentUserIsAdmin();
$rows = [];

foreach ($this->registry->list() as $provider) {
$rows[] = $this->describe($provider, $isAdmin);
}

return [
'openregister' => [
'integrations' => [
'version' => 1,
'providers' => $rows,
],
],
];
}//end getCapabilities()

/**
* Build the role-redacted descriptor for one provider.
*
* @param IntegrationProvider $provider Provider.
* @param bool $isAdmin Whether the caller is admin.
*
* @return array<string,mixed>
*/
private function describe(IntegrationProvider $provider, bool $isAdmin): array
{
$row = [
'id' => $provider->getId(),
'label' => $provider->getLabel(),
'group' => $provider->getGroup(),
'enabled' => $provider->isEnabled(),
'storageStrategy' => $provider->getStorageStrategy(),
'surfaces' => ['user-dashboard', 'app-dashboard', 'detail-page', 'single-entity'],
];

if ($isAdmin === false) {
return $row;
}

$row['requiresPermission'] = $provider->requiresPermission();
$row['openConnectorSource'] = $provider->getOpenConnectorSource();

try {
$row['authStatus'] = $provider->health();
} catch (\Throwable $e) {
$row['authStatus'] = [
'status' => 'unavailable',
'authStatus' => 'unknown',
'message' => 'Provider health check threw',
];
}

return $row;
}//end describe()

/**
* Check whether the current user is in the admin group.
*
* @return bool
*/
private function currentUserIsAdmin(): bool
{
$user = $this->userSession->getUser();
if ($user === null) {
return false;
}

return $this->groupManager->isAdmin($user->getUID());
}//end currentUserIsAdmin()

}//end class
Loading
Loading