diff --git a/.changeset/cloud-registry-services-verified.md b/.changeset/cloud-registry-services-verified.md
new file mode 100644
index 00000000..684e0fce
--- /dev/null
+++ b/.changeset/cloud-registry-services-verified.md
@@ -0,0 +1,5 @@
+---
+"@elixir-cloud/cloud-registry": minor
+---
+
+Add `ecc-client-elixir-cloud-registry-services`: the services list of the service-registry package with a "Verified" badge for verified services. It loads all verifications with one request (`GET /verifications`), and admins can verify services or remove their verification in each row.
diff --git a/.changeset/service-registry-define-once.md b/.changeset/service-registry-define-once.md
new file mode 100644
index 00000000..9333f633
--- /dev/null
+++ b/.changeset/service-registry-define-once.md
@@ -0,0 +1,5 @@
+---
+"@elixir-cloud/service-registry": patch
+---
+
+Define `ecc-client-ga4gh-service-registry-services` only in browsers and only once, so that the services list of the cloud-registry package can define it too.
diff --git a/apps/documentation/pages/docs/cloud-registry/components/_meta.json b/apps/documentation/pages/docs/cloud-registry/components/_meta.json
index 79878531..2517c4a5 100644
--- a/apps/documentation/pages/docs/cloud-registry/components/_meta.json
+++ b/apps/documentation/pages/docs/cloud-registry/components/_meta.json
@@ -1,3 +1,5 @@
{
- "service-create": "Service Create"
-}
\ No newline at end of file
+ "service-create": "Service Create",
+ "service-verification": "Service Verification",
+ "services": "Services"
+}
diff --git a/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx b/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx
new file mode 100644
index 00000000..eb36299b
--- /dev/null
+++ b/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx
@@ -0,0 +1,194 @@
+import { Callout, Tabs } from 'nextra/components'
+import ComponentName from '@/components/common/ComponentName'
+
+# EccClientElixirCloudRegistryServiceVerification Component
+
+
+
+## Overview
+
+The `ecc-client-elixir-cloud-registry-service-verification` component shows whether a service in the Cloud Registry is verified, i.e. whether an admin of the registry checked it (e.g. that it accepts the tokens of the deployment's identity provider). Verified services get a "Verified" badge; its tooltip says when. For admins, the component also offers "Verify" and "Remove verification".
+
+It uses the verification endpoints of Cloud Registry 0.4.0 and later (see [Verifications](/docs/cloud-registry/providers#verifications)):
+
+- `GET /services/{serviceId}/verification` to load the verification of the service;
+- `PUT /services/{serviceId}/verification` to verify it (admins only);
+- `DELETE /services/{serviceId}/verification` to remove its verification (admins only).
+
+
+ The registry removes a verification when the URL or API type of the service changes. Verified doesn't mean trusted: users decide which services to use.
+
+
+## Usage
+
+### One service
+
+The component loads the verification of the service and shows the badge if the service is verified; otherwise it shows nothing. Pass the service with `service`, as the app shows it: the badge then only shows while the verification matches the service's URL and API type, and "Verify" sends them to the registry as the ones that the admin checked, so that a service that has changed meanwhile isn't verified.
+
+
+
+ ```tsx showLineNumbers copy filename="service-verification.tsx"
+ import React, { useMemo } from 'react';
+ import { EccClientElixirCloudRegistryServiceVerification } from '@elixir-cloud/cloud-registry/react/service-verification';
+ import { RestCloudRegistryProvider } from '@elixir-cloud/cloud-registry/providers';
+
+ export default function ServiceHeader({ service, me }) {
+ // created once: a new provider makes the component load again
+ const provider = useMemo(
+ () => new RestCloudRegistryProvider('https://cloud-registry.example.com/ga4gh/registry/v1'),
+ []
+ );
+
+ return (
+
+ );
+ }
+ ```
+
+
+ ```html showLineNumbers copy filename="service-verification.html"
+
+
+
+ ```
+
+
+
+### A list of services
+
+For a list, load all verifications with one request (`getVerifications()`), keep those that still match their services (`verifiedServices()`), and pass each row's verification with `verification`: the rows then send no requests of their own. `null` means that the service isn't verified.
+
+```tsx copy
+import { verifiedServices } from '@elixir-cloud/cloud-registry/providers';
+
+const [services, verifications] = await Promise.all([
+ provider.getServices(),
+ provider.getVerifications(),
+]);
+// e.g. in React state, so that the rows follow verify and remove; after
+// reloading the lists, call setVerified(verifiedServices(...)) again
+const [verified, setVerified] = useState(
+ () => verifiedServices(services, verifications)
+);
+// `me` is the response of the registry's GET /me
+
+services.map((service) => (
+
+ setVerified((current) => {
+ const next = new Map(current);
+ if (detail.verification) {
+ next.set(detail.serviceId, { serviceId: detail.serviceId, ...detail.verification });
+ } else {
+ next.delete(detail.serviceId);
+ }
+ return next;
+ })
+ }
+ />
+));
+```
+
+After a verify or remove, the component shows the result for that service until the app sets `verification` again, also if the provider is replaced meanwhile by one for the same registry (the same base URL, e.g. with a new token); update your verifications from `ecc-service-verification-changed`, as above. Pass a stable value (e.g. the object from the map, not one built on each render): every new value counts as set again.
+
+
+ For the registry's services list with badges, use [`ecc-client-elixir-cloud-registry-services`](./services), which does all of this. The list of the GA4GH Service Registry package (`ecc-client-ga4gh-service-registry-services`) doesn't show verifications itself.
+
+
+## Properties
+
+| Property | Type | Default | Description |
+|----------------|-------------------------|-------------|-------------|
+| `baseUrl` | `string` | `""` | Base URL of the Cloud Registry API, if no `provider` is set |
+| `provider` | `CloudRegistryProvider` | `undefined` | Data provider (see [Providers](/docs/cloud-registry/providers)); takes precedence over `baseUrl` |
+| `serviceId` | `string` | `""` | ID of the service (attribute `service-id`); defaults to the ID of `service` |
+| `service` | `ExternalService` | `undefined` | The service as the app shows it: the badge only shows while the verification matches its URL and API type, and "Verify" sends them as checked |
+| `verification` | `Verification \| null` | `undefined` | The verification if the app has loaded it (`null`: not verified); if `undefined`, the component loads it |
+| `admin` | `boolean` | `false` | Whether to offer "Verify" and "Remove verification" |
+
+### `provider` and `baseUrl`
+The component loads the verification again when `provider`, `baseUrl`, `serviceId` or the ID of `service` changes, also if the app sets them after the first render; if the app passes `verification`, it shows that instead (or the result of a verify or remove since, until the app sets `verification` again). A new object of the same service (e.g. of a reloaded list) needs no new request, unless its URL or API type changed: the registry then removes the verification. In React, create the provider only when its inputs change, e.g. with `useMemo`.
+
+Custom providers implement the optional verification methods of `CloudRegistryProvider`; without them, the component fires `ecc-service-verification-failed`.
+
+### `service`
+Without `service`, the component shows the registry's verification as it is, and "Verify" verifies the service as it is registered at the time of the request. With `service`, a verification of another URL (compared as written) or API type (`type.group` and `type.artifact`, trimmed and case-insensitive, as the registry compares them) doesn't count.
+
+### `admin`
+Apps take the value from `admin` of the registry's `GET /me`. The property only changes what the component offers: the registry checks permissions itself and answers other users with `403`. For admins, unverified services show a "Not verified" badge and a "Verify" button; verified services show "Remove verification".
+
+## Events
+
+| Event Name | React Event Name | Detail Type | Description |
+|------------------------------------|------------------------------------|-------------|-------------|
+| `ecc-service-verification-changed` | `onEccServiceVerificationChanged` | `{ serviceId: string, verification: Verification \| null }` | Fired when an admin verified the service or removed its verification |
+| `ecc-service-verification-failed` | `onEccServiceVerificationFailed` | `{ serviceId: string, action: "load" \| "verify" \| "remove", error: string, status?: number, title?: string, detail?: string, code?: string, cause?: unknown }` | Fired when loading, verifying or removing the verification fails |
+
+### `ecc-service-verification-changed`
+Emitted after an admin verified the service (`verification`: the new verification) or removed its verification (`null`). A verification that was removed already (`404`) counts as removed. The event is fired whenever the registry has applied the change, also if the component shows another service by then (`serviceId` names the service). Apps that pass `verification` update it from this event, e.g. in their map of verified services.
+
+### `ecc-service-verification-failed`
+Emitted when a request fails. Services that aren't verified (`404` when loading) are no failure.
+
+- `action`: what failed: `load`, `verify` or `remove`;
+- `error`: what went wrong, e.g. the registry's explanation (the problem's `detail`);
+- `status`: the HTTP status, e.g. `403` (not an admin) or `409`;
+- `title`, `detail`: the problem details that the registry sent, if any;
+- `code`: the conflict code of a `409`: `verification-mismatch` if the service has another URL or API type than the one shown (the component then loads the verification again, unless the app passes `verification`), `pending-service` if the service awaits approval. After a failure without a status (e.g. a lost connection) or with a server error, the component also loads the verification again, as the registry may have applied the change;
+- `cause`: the error that the provider threw.
+
+
+
+ ```tsx copy
+ {
+ if (event.detail.code === 'verification-mismatch') {
+ alert('The service has changed. Reload it and check it again.');
+ } else {
+ alert(event.detail.error);
+ }
+ }}
+ />
+ ```
+
+
+ ```javascript copy
+ element.addEventListener('ecc-service-verification-failed', (event) => {
+ console.error(event.detail.action, event.detail.status, event.detail.error);
+ });
+ ```
+
+
+
+## CSS parts
+
+| Part | Description |
+|-----------------|-------------|
+| `badge` | The "Verified" (for admins also "Not verified") badge |
+| `verify-button` | The "Verify" button (admins) |
+| `remove-button` | The "Remove verification" button (admins) |
diff --git a/apps/documentation/pages/docs/cloud-registry/components/services.mdx b/apps/documentation/pages/docs/cloud-registry/components/services.mdx
new file mode 100644
index 00000000..f9215e4c
--- /dev/null
+++ b/apps/documentation/pages/docs/cloud-registry/components/services.mdx
@@ -0,0 +1,101 @@
+import { Callout, Tabs } from 'nextra/components'
+import ComponentName from '@/components/common/ComponentName'
+
+# EccClientElixirCloudRegistryServices Component
+
+
+
+## Overview
+
+The `ecc-client-elixir-cloud-registry-services` component lists the services of a Cloud Registry and shows a "Verified" badge for the verified ones. It is the [services list](/docs/service-registry/components/services) of the GA4GH Service Registry package (search, filters, pages, row actions) with a [service verification](./service-verification) component in each row.
+
+It loads all verifications with one request (`GET /verifications`, Cloud Registry 0.4.0 and later), not one per row. A verification only counts while the service still has the verified URL and API type (see [Verifications](/docs/cloud-registry/providers#verifications)). Rows show the badge once the verifications are loaded; if loading fails, the list shows no badges and fires `ecc-service-verification-failed`.
+
+## Usage
+
+
+
+ ```tsx showLineNumbers copy filename="services.tsx"
+ import React, { useMemo } from 'react';
+ import { EccClientElixirCloudRegistryServices } from '@elixir-cloud/cloud-registry/react/services';
+ import { RestCloudRegistryProvider } from '@elixir-cloud/cloud-registry/providers';
+
+ export default function Services({ me }) {
+ // created once: a new provider makes the list load again
+ const provider = useMemo(
+ () => new RestCloudRegistryProvider('https://cloud-registry.example.com/ga4gh/registry/v1'),
+ []
+ );
+
+ return (
+ console.log(event.detail.serviceId)}
+ />
+ );
+ }
+ ```
+
+
+ ```html showLineNumbers copy filename="services.html"
+
+
+
+ ```
+
+
+
+## Properties
+
+| Property | Type | Default | Description |
+|--------------|-----------------------------------------|-------------|-------------|
+| `baseUrl` | `string` | `""` | Base URL of the Cloud Registry API, if no `provider` is set |
+| `provider` | `CloudRegistryProvider` | `undefined` | Data provider (see [Providers](/docs/cloud-registry/providers)); takes precedence over `baseUrl` |
+| `admin` | `boolean` | `false` | Whether to offer "Verify" and "Remove verification" in each row |
+| `filter` | `boolean` | `true` | Whether to show the type, organization and environment filters |
+| `search` | `boolean` | `true` | Whether to show the search field |
+| `pageSize` | `number` | `10` | Services per page (attribute `page-size`); 0 or less shows all services on one page, an empty attribute 10 |
+| `rowActions` | `(service: ExternalService) => unknown` | `undefined` | Renders the actions of each row after the badge; without it, each row has a "View Details" button |
+
+### `provider` and `baseUrl`
+Replacing the provider (or changing `baseUrl`) renders a new list, which loads the services and the verifications again. In React, create the provider only when its inputs change, e.g. with `useMemo`. Custom providers implement the optional `getVerifications()` of `CloudRegistryProvider`; without it, the list shows no badges and fires `ecc-service-verification-failed`.
+
+### `admin`
+Apps take the value from `admin` of the registry's `GET /me`. Admins see "Not verified" and "Verify" for unverified services and "Remove verification" for verified ones. The list follows their changes without loading the verifications again, also for rows that are no longer shown by then (e.g. on another page). After a failed verify or remove, it loads the verifications again, as the registry may have applied the change. The registry checks permissions itself.
+
+### `filter`, `search` and `pageSize`
+As in the [services list](/docs/service-registry/components/services) of the GA4GH Service Registry package, which this component passes them to.
+
+### `rowActions`
+As in the [services list](/docs/service-registry/components/services#rowactions): a function that renders the actions of a row from its service, e.g. Edit and Delete buttons. They follow the badge. Set a new function when what it renders changes.
+
+
+ The `actions-` slots of the services list aren't available through this component; use `rowActions` instead.
+
+
+## Events
+
+| Event Name | React Event Name | Detail Type | Description |
+|------------------------------------|-----------------------------------|-------------|-------------|
+| `ecc-services-changed` | `onEccServicesChanged` | `{ services: ExternalService[] }` | Fired when the services are loaded |
+| `ecc-service-selected` | `onEccServiceSelected` | `{ serviceId: string }` | Fired when a service, its row or its "View Details" button is clicked |
+| `ecc-service-verification-changed` | `onEccServiceVerificationChanged` | `{ serviceId: string, verification: Verification \| null }` | Fired when an admin verified a service or removed its verification |
+| `ecc-service-verification-failed` | `onEccServiceVerificationFailed` | `{ serviceId: string, action: "load" \| "verify" \| "remove", error: string, status?: number, ... }` | Fired when loading the verifications, or verifying a service or removing its verification, fails |
+
+### `ecc-service-verification-failed`
+When loading the verifications of the list fails, `serviceId` is empty and `action` is `load`; `status` is the HTTP status if there is one, and `cause` the error that the provider threw. Failures of a row's verify or remove carry the row's service ID, as described for the [service verification](./service-verification#ecc-service-verification-failed) component.
+
+## CSS parts
+
+| Part | Description |
+|--------|-------------|
+| `list` | The services list (`ecc-client-ga4gh-service-registry-services`) |
diff --git a/apps/documentation/pages/docs/cloud-registry/introduction.mdx b/apps/documentation/pages/docs/cloud-registry/introduction.mdx
index 990a5bfc..d869dbb6 100644
--- a/apps/documentation/pages/docs/cloud-registry/introduction.mdx
+++ b/apps/documentation/pages/docs/cloud-registry/introduction.mdx
@@ -20,6 +20,8 @@ The `Cloud Registry` package provides web components for managing and creating s
## Available Components
- [``](./components/service-create): A component for creating new service registrations.
+- [``](./components/service-verification): A component that shows whether a service is verified, and lets admins verify it or remove its verification.
+- [``](./components/services): The services list of the registry, with a "Verified" badge for verified services.
- [``](../service-registry/components/services): A component for listing existing service registrations.
- [``](../service-registry/components/service): A component for displaying details of a specific service.
diff --git a/apps/documentation/pages/docs/cloud-registry/providers.mdx b/apps/documentation/pages/docs/cloud-registry/providers.mdx
index 235fa126..7ee7ca95 100644
--- a/apps/documentation/pages/docs/cloud-registry/providers.mdx
+++ b/apps/documentation/pages/docs/cloud-registry/providers.mdx
@@ -33,6 +33,12 @@ export interface CloudRegistryProvider extends ServiceRegistryProvider {
// Service info methods (Cloud Registry specific)
createOrUpdateServiceInfo?(service: ServiceRegister): Promise;
+
+ // Verification methods (Cloud Registry 0.4.0 and later; see Verifications)
+ getVerifications?(): Promise;
+ getServiceVerification?(id: string): Promise;
+ verifyService?(id: string, verification?: VerificationRegister): Promise;
+ deleteServiceVerification?(id: string): Promise;
}
```
@@ -155,6 +161,48 @@ Creates or updates the service registry's own service information.
createOrUpdateServiceInfo?(service: ServiceRegister): Promise
```
+### Verifications
+
+Cloud Registry 0.4.0 and later let admins verify services, e.g. after checking that they accept the tokens of the deployment's identity provider. The methods are optional in `CloudRegistryProvider`, so that custom providers without them still conform; `RestCloudRegistryProvider` has them all. The [Service Verification component](/docs/cloud-registry/components/service-verification) uses them.
+
+```typescript
+// GET /verifications: the verified services, sorted by ID
+getVerifications?(): Promise
+
+// GET /services/{id}/verification: rejects with status 404 if not verified
+getServiceVerification?(id: string): Promise
+
+// PUT /services/{id}/verification (admins): with the URL and API type that
+// were checked, rejects with status 409 (problem `code`
+// `verification-mismatch`) if the service no longer has them; without
+// them, verifies the service as registered
+verifyService?(id: string, verification?: VerificationRegister): Promise
+
+// DELETE /services/{id}/verification (admins): rejects with status 404 if
+// not verified; resolves to the service ID
+deleteServiceVerification?(id: string): Promise
+```
+
+A verification only counts while the service still has the verified URL (as written) and API type (`type.group` and `type.artifact`, trimmed and case-insensitive; not `type.version`). The registry removes verifications when these change, but a list of services and a list of verifications loaded one after the other may disagree. Two helpers compare them as the registry does:
+
+```typescript
+import {
+ matchesVerification,
+ verifiedServices,
+} from '@elixir-cloud/cloud-registry/providers';
+
+const [services, verifications] = await Promise.all([
+ provider.getServices(),
+ provider.getVerifications(),
+]);
+
+// Map of service ID to verification, for the services that are verified
+const verified = verifiedServices(services, verifications);
+
+// Whether one verification still matches a service (or form values)
+matchesVerification(service, verification);
+```
+
## Errors
If the registry answers with an error status, the methods of `RestCloudRegistryProvider` reject with a `CloudRegistryError`. It extends `Error` and carries the status and the [problem details](https://www.rfc-editor.org/rfc/rfc9457) that the registry sends:
@@ -166,6 +214,7 @@ If the registry answers with an error status, the methods of `RestCloudRegistryP
| `title` | `string \| undefined` | Short summary of the problem type, e.g. "Conflict" |
| `detail` | `string \| undefined` | Explanation of this occurrence, e.g. which field is invalid |
| `serviceId` | `string \| undefined` | ID of the existing service, for duplicate registrations (`409`) |
+| `code` | `string \| undefined` | Why a request conflicts (`409`), e.g. `verification-mismatch`; treat unknown codes as a general conflict |
| `problem` | `ProblemDetails \| undefined` | The whole response body, if it was a JSON object |
```typescript
@@ -257,6 +306,28 @@ interface RegistrationRules {
}
```
+### Verification, ServiceVerification and VerificationRegister
+
+```typescript
+// GET and PUT /services/{id}/verification
+interface Verification {
+ url: string; // URL of the verified service, as registered
+ type: ServiceType; // API type of the verified service
+ verifiedAt: string; // when an admin verified it (RFC 3339, UTC)
+}
+
+// GET /verifications
+interface ServiceVerification extends Verification {
+ serviceId: string;
+}
+
+// Body of PUT /services/{id}/verification: what the admin checked
+interface VerificationRegister {
+ url: string; // exactly as registered
+ type: ServiceTypeRegister; // `version` isn't compared
+}
+```
+
## Using with Components
### Service Creation
diff --git a/apps/documentation/pages/docs/service-registry/components/services.mdx b/apps/documentation/pages/docs/service-registry/components/services.mdx
index 0293e4a8..3b1885ba 100644
--- a/apps/documentation/pages/docs/service-registry/components/services.mdx
+++ b/apps/documentation/pages/docs/service-registry/components/services.mdx
@@ -168,7 +168,7 @@ Controls whether the search input field is displayed. The search matches the ID,
Controls whether the filters are displayed: by type (the types the registry lists at `/services/types`, or else those of the loaded services), by organization, and by environment (only if a service has one). The filters, the search and the page combine; changing the search or a filter goes back to the first page. Only filters on display apply, and only with a value that is still among their options (e.g. after a reload from another registry), so the list never narrows without a visible reason.
- The GA4GH Service Registry API doesn't describe whether a service is verified, so there is no filter for it yet.
+ The GA4GH Service Registry API doesn't describe whether a service is verified, so there is no filter for it yet. To show which services of a Cloud Registry are verified, use [`ecc-client-elixir-cloud-registry-services`](/docs/cloud-registry/components/services).
diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/index.ts b/packages/ecc-client-elixir-cloud-registry/src/components/index.ts
index 42b8556d..aad15376 100644
--- a/packages/ecc-client-elixir-cloud-registry/src/components/index.ts
+++ b/packages/ecc-client-elixir-cloud-registry/src/components/index.ts
@@ -1 +1,3 @@
import "./service-create/index.js";
+import "./service-verification/index.js";
+import "./services/index.js";
diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/index.ts b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/index.ts
new file mode 100644
index 00000000..7d3b2b1a
--- /dev/null
+++ b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/index.ts
@@ -0,0 +1,25 @@
+import ECCClientElixirCloudRegistryServiceVerification from "./service-verification.js";
+
+export * from "./service-verification.js";
+export default ECCClientElixirCloudRegistryServiceVerification;
+
+// Only in browsers, and only once (the services list of the
+// cloud-registry package may have defined it already)
+if (
+ typeof window !== "undefined" &&
+ window.customElements &&
+ !window.customElements.get(
+ "ecc-client-elixir-cloud-registry-service-verification"
+ )
+) {
+ window.customElements.define(
+ "ecc-client-elixir-cloud-registry-service-verification",
+ ECCClientElixirCloudRegistryServiceVerification
+ );
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ecc-client-elixir-cloud-registry-service-verification": ECCClientElixirCloudRegistryServiceVerification;
+ }
+}
diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts
new file mode 100644
index 00000000..2e0f235c
--- /dev/null
+++ b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts
@@ -0,0 +1,494 @@
+import { LitElement, html, css, nothing } from "lit";
+import { property, state } from "lit/decorators.js";
+import { ComponentStyles as TailwindStyles } from "./tw-styles.js";
+import { GlobalStyles } from "../../global.js";
+import { RestCloudRegistryProvider } from "../../providers/rest-cr-provider.js";
+import {
+ CloudRegistryProvider,
+ ExternalService,
+ Verification,
+} from "../../providers/index.js";
+import { matchesVerification } from "../../providers/verifications.js";
+import { nonBlankString } from "../../providers/errors.js";
+import type { EccServiceVerificationFailedEvent } from "../../events/index.js";
+import "@elixir-cloud/design/components/badge/index.js";
+import "@elixir-cloud/design/components/button/index.js";
+
+type FailureDetail = EccServiceVerificationFailedEvent["detail"];
+
+/** Status of an error that a provider threw, if it has one */
+const statusOf = (err: unknown): number | undefined => {
+ const status =
+ err && typeof err === "object"
+ ? (err as Record).status
+ : undefined;
+ return typeof status === "number" ? status : undefined;
+};
+
+/**
+ * Detail of `ecc-service-verification-failed` for an error that a provider
+ * threw: its message, and the status and problem details if it has them.
+ * They are read from the error's fields, as `CloudRegistryError` has them,
+ * so that errors of custom providers with the same fields work too.
+ * @param serviceId ID of the service
+ * @param action What failed
+ * @param err The error
+ * @param fallback Message if the error has none
+ * @returns The event's detail
+ */
+const failureDetail = (
+ serviceId: string,
+ action: FailureDetail["action"],
+ err: unknown,
+ fallback: string
+): FailureDetail => {
+ const fields =
+ err && typeof err === "object" ? (err as Record) : {};
+ const detail = nonBlankString(fields.detail);
+ return {
+ serviceId,
+ action,
+ error:
+ nonBlankString(fields.message) ??
+ detail ??
+ nonBlankString(err) ??
+ fallback,
+ status: statusOf(err),
+ title: nonBlankString(fields.title),
+ detail,
+ code: nonBlankString(fields.code),
+ cause: err,
+ };
+};
+
+/**
+ * Key of a running verify or remove: the service ID and the registry's base
+ * URL, if the provider has one
+ */
+const pendingKey = (id: string, provider: CloudRegistryProvider): string => {
+ const { baseUrl } = provider as { baseUrl?: unknown };
+ return JSON.stringify([typeof baseUrl === "string" ? baseUrl : "", id]);
+};
+
+/** Date of a verification for people, e.g. "20 Oct 2026"; else as sent */
+const formatDate = (value: string): string => {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return value;
+ return date.toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+};
+
+/**
+ * @summary Shows whether a service in Cloud Registry is verified, and lets admins verify it or remove its verification
+ * @since 2.0.0
+ *
+ * @property {string} baseUrl - Base URL of the Cloud Registry instance/gateway
+ * @property {CloudRegistryProvider} provider - Custom data provider (optional, overrides baseUrl); replacing it loads the verification again
+ * @property {string} serviceId - ID of the service
+ * @property {ExternalService} service - The service as the app shows it (optional): a verification only counts while it matches the service's URL and API type, and "Verify" sends them as checked
+ * @property {Verification | null} verification - The verification, if the app has loaded it already (optional; `null`: not verified), e.g. from `getVerifications()` for a list; else the component loads it
+ * @property {boolean} admin - Whether to offer "Verify" and "Remove verification" (for admins of the registry); default `false`
+ *
+ * @fires ecc-service-verification-changed - Fired when an admin verified the service or removed its verification (includes the service ID and the new verification, or `null`)
+ * @fires ecc-service-verification-failed - Fired when loading, verifying or removing the verification fails (includes the action, the error message and, if the registry sent them, the status, problem details and conflict code)
+ *
+ * @csspart badge - The "Verified" (or, for admins, "Not verified") badge
+ * @csspart verify-button - The "Verify" button (admins)
+ * @csspart remove-button - The "Remove verification" button (admins)
+ */
+export class ECCClientElixirCloudRegistryServiceVerification extends LitElement {
+ static styles = [
+ TailwindStyles,
+ GlobalStyles,
+ css`
+ :host {
+ display: inline-block;
+ }
+ `,
+ ];
+
+ @property({ type: String, reflect: true }) baseUrl = "";
+ @property({ attribute: false }) provider?: CloudRegistryProvider;
+
+ /** ID of the service; defaults to the ID of `service` */
+ @property({ type: String, attribute: "service-id" }) serviceId = "";
+
+ /**
+ * The service as the app shows it. A verification only counts while it
+ * matches the service's URL (as written) and API type (`group`,
+ * `artifact`), as the registry compares them; "Verify" sends them as the
+ * ones that the admin checked, so that a service that changed meanwhile
+ * isn't verified (`409`). Without it, the component trusts the registry's
+ * verification and "Verify" verifies the service as it is registered.
+ */
+ @property({ attribute: false }) service?: ExternalService;
+
+ /**
+ * The verification, if the app has loaded it already: e.g. from
+ * `getVerifications()` (with `verifiedServices()`) for a list of
+ * services, so that the rows need no request each. `null` means not
+ * verified; `undefined` (the default) makes the component load it.
+ */
+ @property({ attribute: false }) verification?: Verification | null;
+
+ /**
+ * Whether to offer "Verify" and "Remove verification". Apps set it from
+ * `admin` of the registry's `GET /me`; the registry checks permissions
+ * itself.
+ */
+ @property({ type: Boolean }) admin = false;
+
+ // The verification shown: `undefined` while it loads or if loading failed
+ @state() private current?: Verification | null;
+ // IDs of the services with a verify or remove request running
+ @state() private pending: ReadonlySet = new Set();
+
+ /** The provider for `baseUrl`, used if `provider` isn't set. */
+ private restProvider: RestCloudRegistryProvider | null = null;
+
+ // Number of the latest load, so that older answers are ignored (e.g. of a
+ // previous service or provider, or read before a verify or remove)
+ private loadRequest = 0;
+
+ // Result of the last verify or remove: shown instead of `verification`
+ // for that service of that registry until the app sets `verification`
+ // again
+ private own?: {
+ id: string;
+ verification: Verification | null;
+ provider: CloudRegistryProvider;
+ };
+
+ /** ID of the service: `serviceId`, else the ID of `service`. */
+ private get targetId(): string {
+ return this.serviceId || this.service?.id || "";
+ }
+
+ /** Whether a verify or remove of the service shown is running. */
+ private get busy(): boolean {
+ const provider = this.currentProvider();
+ return Boolean(
+ provider && this.pending.has(pendingKey(this.targetId, provider))
+ );
+ }
+
+ /** Whether the service is verified, as far as is known. */
+ private get verified(): boolean {
+ return Boolean(
+ this.current &&
+ (!this.service || matchesVerification(this.service, this.current))
+ );
+ }
+
+ protected updated(changedProperties: Map): void {
+ if (changedProperties.has("verification")) this.own = undefined;
+ // a new object of the same service (e.g. of a reloaded list) needs no
+ // new request, unless its URL or API type changed: the registry then
+ // removes the verification, which must not show again if they change back
+ const previous = changedProperties.get("service") as
+ | ExternalService
+ | undefined;
+ const { service } = this;
+ const serviceChanged =
+ changedProperties.has("service") &&
+ (previous?.id !== service?.id ||
+ !previous ||
+ !service ||
+ !matchesVerification(previous, service));
+ if (
+ changedProperties.has("provider") ||
+ changedProperties.has("baseUrl") ||
+ changedProperties.has("serviceId") ||
+ changedProperties.has("verification") ||
+ serviceChanged
+ ) {
+ this.load();
+ }
+ }
+
+ /**
+ * The provider to use now: `provider`, else a REST provider for `baseUrl`
+ * (also right after an app set them, before the component updated).
+ */
+ private currentProvider(): CloudRegistryProvider | null {
+ if (this.baseUrl && this.restProvider?.baseUrl !== this.baseUrl) {
+ this.restProvider = new RestCloudRegistryProvider(this.baseUrl);
+ } else if (!this.baseUrl) {
+ this.restProvider = null;
+ }
+ return this.provider ?? this.restProvider;
+ }
+
+ private fail(detail: FailureDetail): void {
+ this.dispatchEvent(
+ new CustomEvent("ecc-service-verification-failed", {
+ detail,
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+
+ /**
+ * Shows `verification` if the app set it (or the result of a verify or
+ * remove since), else loads the verification of the service; services
+ * that aren't verified (`404`) show none.
+ */
+ private async load(): Promise {
+ this.loadRequest += 1;
+ const request = this.loadRequest;
+ const id = this.targetId;
+ if (this.verification !== undefined) {
+ const { own } = this;
+ this.current =
+ own && this.showsSame(own.id, own.provider)
+ ? own.verification
+ : this.verification;
+ return;
+ }
+ this.current = undefined;
+ const provider = this.currentProvider();
+ if (!provider || !id) return;
+ if (!provider.getServiceVerification) {
+ this.fail({
+ serviceId: id,
+ action: "load",
+ error: "The provider doesn't support verifications.",
+ });
+ return;
+ }
+ try {
+ const verification = await provider.getServiceVerification(id);
+ if (request !== this.loadRequest) return;
+ this.current = verification;
+ } catch (err) {
+ if (request !== this.loadRequest) return;
+ if (statusOf(err) === 404) {
+ this.current = null;
+ return;
+ }
+ this.fail(
+ failureDetail(id, "load", err, "Failed to load the verification")
+ );
+ }
+ }
+
+ /**
+ * Whether the component still shows the service of a request: the same ID
+ * and registry (the same provider, or one for the same base URL, e.g. one
+ * that an app replaced for a new token)
+ */
+ private showsSame(id: string, provider: CloudRegistryProvider): boolean {
+ const current = this.currentProvider();
+ const { baseUrl } = provider as { baseUrl?: unknown };
+ return (
+ id === this.targetId &&
+ (current === provider ||
+ (typeof baseUrl === "string" &&
+ baseUrl === (current as { baseUrl?: unknown } | null)?.baseUrl))
+ );
+ }
+
+ /** Marks a verify or remove of a service as running, or as done. */
+ private setPending(
+ id: string,
+ provider: CloudRegistryProvider,
+ running: boolean
+ ): void {
+ const pending = new Set(this.pending);
+ if (running) pending.add(pendingKey(id, provider));
+ else pending.delete(pendingKey(id, provider));
+ this.pending = pending;
+ }
+
+ /**
+ * Loads the verification again after a failed verify or remove whose
+ * outcome is unclear (no status, e.g. a lost connection, or a server
+ * error) or that conflicts (`409`), if the component still shows that
+ * service and doesn't use the app's `verification`
+ */
+ private reconcile(
+ err: unknown,
+ id: string,
+ provider: CloudRegistryProvider
+ ): void {
+ const status = statusOf(err);
+ if (
+ (status === undefined || status === 409 || status >= 500) &&
+ this.showsSame(id, provider) &&
+ this.verification === undefined
+ ) {
+ this.load();
+ }
+ }
+
+ /**
+ * Applies the result of a verify or remove: always reported, as the
+ * registry has applied it, but shown only if the component still shows
+ * that service of that registry.
+ */
+ private applied(
+ id: string,
+ verification: Verification | null,
+ provider: CloudRegistryProvider
+ ): void {
+ if (this.showsSame(id, provider)) {
+ // a load that runs now may have read the state before the change
+ this.loadRequest += 1;
+ this.current = verification;
+ if (this.verification !== undefined) {
+ this.own = { id, verification, provider };
+ }
+ }
+ this.changed(id, verification);
+ }
+
+ /**
+ * Verifies the service (admins), with the URL and API type of `service`
+ * as the ones checked. If the service has changed meanwhile (`409`), the
+ * verification is loaded again.
+ */
+ private async verifyService(): Promise {
+ const provider = this.currentProvider();
+ const { targetId: id, service } = this;
+ if (!provider || !id || this.busy) return;
+ if (!provider.verifyService) {
+ this.fail({
+ serviceId: id,
+ action: "verify",
+ error: "The provider doesn't support verifications.",
+ });
+ return;
+ }
+ this.setPending(id, provider, true);
+ try {
+ const verification = await provider.verifyService(
+ id,
+ service
+ ? {
+ url: service.url,
+ type: {
+ group: service.type.group,
+ artifact: service.type.artifact,
+ version: service.type.version,
+ },
+ }
+ : undefined
+ );
+ this.setPending(id, provider, false);
+ this.applied(id, verification, provider);
+ } catch (err) {
+ this.setPending(id, provider, false);
+ this.fail(failureDetail(id, "verify", err, "Failed to verify service"));
+ this.reconcile(err, id, provider);
+ }
+ }
+
+ /**
+ * Removes the verification of the service (admins); one that is gone
+ * already (`404`) counts as removed.
+ */
+ private async removeVerification(): Promise {
+ const provider = this.currentProvider();
+ const id = this.targetId;
+ if (!provider || !id || this.busy) return;
+ if (!provider.deleteServiceVerification) {
+ this.fail({
+ serviceId: id,
+ action: "remove",
+ error: "The provider doesn't support verifications.",
+ });
+ return;
+ }
+ this.setPending(id, provider, true);
+ try {
+ await provider.deleteServiceVerification(id);
+ } catch (err) {
+ if (statusOf(err) !== 404) {
+ this.setPending(id, provider, false);
+ this.fail(
+ failureDetail(id, "remove", err, "Failed to remove verification")
+ );
+ this.reconcile(err, id, provider);
+ return;
+ }
+ }
+ this.setPending(id, provider, false);
+ this.applied(id, null, provider);
+ }
+
+ private changed(serviceId: string, verification: Verification | null) {
+ this.dispatchEvent(
+ new CustomEvent("ecc-service-verification-changed", {
+ detail: { serviceId, verification },
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+
+ render() {
+ // nothing while loading, and nothing else for unverified services
+ // unless the user is an admin
+ const { current, verified } = this;
+ if (current === undefined) return nothing;
+ if (!verified && !this.admin) return nothing;
+
+ const badge = verified
+ ? html`
+
+ Verified
+ `
+ : html`
+ Not verified
+ `;
+
+ if (!this.admin) return badge;
+
+ return html`
+