From 46f32c2df278b1c17dcb6023e94e6b0949c24c82 Mon Sep 17 00:00:00 2001 From: Carsten Rehfeld Date: Thu, 14 May 2026 15:49:03 +0200 Subject: [PATCH] chore: add missing source modules to version control apix-demo, apix-portal/src, apix-spider/src, apix-registry/src, apix-common/src were never staged. Without them the CI build has no source to compile and the Docker images cannot be produced. Also adds docs/ (infrastructure notes) missed in prior commits. Co-Authored-By: Mira --- .../botstandards/apix/common/BsmPayload.java | 60 +- .../org/botstandards/apix/common/OLevel.java | 15 + .../org/botstandards/apix/common/OrgType.java | 13 + .../apix/common/SandboxDashboardResponse.java | 31 + .../apix/common/ServiceStage.java | 13 + apix-demo/pom.xml | 88 ++ .../apix/demo/client/RegistryClient.java | 34 + .../demo/client/dto/RegistrationRequest.java | 4 + .../apix/demo/client/dto/SandboxCreated.java | 13 + .../apix/demo/entity/DemoConfigEntry.java | 17 + .../apix/demo/entity/MockServiceConfig.java | 48 ++ .../demo/resource/MockDispatcherResource.java | 52 ++ .../apix/demo/service/DemoSeedService.java | 327 ++++++++ .../demo/service/MockDispatcherService.java | 89 ++ .../apix/demo/service/RateLimiterService.java | 30 + .../src/main/resources/application.properties | 23 + .../db/changelog/changes/001-demo-schema.xml | 74 ++ .../db/changelog/db.changelog-master.xml | 10 + .../apix/portal/client/RegistryClient.java | 19 + .../portal/resource/DashboardResource.java | 62 ++ .../resources/META-INF/resources/index.html | 565 +++++++++++++ .../src/main/resources/application.properties | 7 + .../DashboardResource/dashboard.html | 295 +++++++ .../templates/DashboardResource/error.html | 24 + .../templates/DashboardResource/notFound.html | 25 + .../apix/registry/bdd/IotTransitionSteps.java | 10 +- .../apix/registry/bdd/OrgOnboardingSteps.java | 18 + .../apix/registry/bdd/SandboxCucumberIT.java | 23 + .../apix/registry/bdd/SandboxSteps.java | 556 ++++++++++++ .../apix/registry/bdd/TestSetup.java | 6 +- .../registry/bdd/device/DeviceCucumberIT.java | 29 + .../bdd/device/DeviceNavigationSteps.java | 192 +++++ .../devices/device-navigation.feature | 56 ++ .../org-onboarding/org-audit-log.feature | 3 +- .../org-onboarding/org-bsf-grants.feature | 48 +- .../org-key-rotation-dns.feature | 14 +- .../features/sandbox/sandbox-feedback.feature | 58 ++ .../sandbox/sandbox-navigation.feature | 33 + .../sandbox/sandbox-registration.feature | 32 + .../sandbox/sandbox-service-isolation.feature | 45 + .../sandbox/sandbox-telemetry.feature | 43 + .../sandbox/sandbox-tier-caps.feature | 8 + .../apix/registry/RegistryApiConfig.java | 52 ++ .../registry/dto/DeviceIndexResponse.java | 17 + .../dto/FeedbackAggregateResponse.java | 21 + .../apix/registry/dto/FeedbackDimension.java | 9 + .../registry/dto/FeedbackSchemaResponse.java | 15 + .../dto/FeedbackSubmissionRequest.java | 25 + .../apix/registry/dto/IndexResponse.java | 44 + .../registry/dto/OrgRegistrationRequest.java | 15 + .../registry/dto/SandboxIndexResponse.java | 13 + .../apix/registry/dto/SandboxLinks.java | 20 + .../dto/SandboxRegistrationRequest.java | 27 + .../dto/SandboxRegistrationResponse.java | 21 + .../dto/SandboxTelemetryResponse.java | 24 + .../apix/registry/dto/ServiceResponse.java | 8 +- .../apix/registry/entity/SandboxEntity.java | 59 ++ .../apix/registry/entity/ServiceEntity.java | 5 +- .../registry/filter/CacheControlFilter.java | 63 ++ .../registry/filter/CanonicalQueryFilter.java | 82 ++ .../QueryNormalisationService.java | 104 +++ .../registry/resource/DeviceResource.java | 95 +++ .../apix/registry/resource/IndexResource.java | 68 ++ .../registry/resource/SandboxResource.java | 280 +++++++ .../registry/resource/ServiceResource.java | 86 +- .../apix/registry/service/GeoService.java | 108 +++ .../registry/service/OrganizationService.java | 2 +- .../registry/service/RegistryService.java | 84 +- .../apix/registry/service/SandboxService.java | 582 +++++++++++++ .../src/main/resources/application.properties | 22 + .../changes/012-capabilities-gin-index.xml | 22 + .../db/changelog/changes/013-sandbox.xml | 67 ++ .../changelog/changes/014-sandbox-usage.xml | 41 + .../db/changelog/changes/015-sandbox-caps.xml | 28 + .../changes/016-sandbox-feedback.xml | 40 + .../changes/017-feedback-model-info.xml | 28 + .../db/changelog/changes/018-sandbox-geo.xml | 26 + .../changes/019-sandbox-agent-visits.xml | 47 ++ .../changes/020-sandbox-uuid-routing.xml | 30 + .../changes/021-sandbox-maintenance-key.xml | 28 + .../changelog/changes/022-optional-email.xml | 17 + .../db/changelog/db.changelog-master.xml | 11 + .../apix/registry/service/GeoServiceTest.java | 63 ++ .../apix/spider/SandboxCleanupJob.java | 99 +++ .../src/main/resources/application.properties | 10 + docs/dns-migration-ionos-to-bunnynet.md | 278 ++++++ docs/infrastructure-setup.md | 793 ++++++++++++++++++ 87 files changed, 6657 insertions(+), 34 deletions(-) create mode 100644 apix-common/src/main/java/org/botstandards/apix/common/SandboxDashboardResponse.java create mode 100644 apix-demo/pom.xml create mode 100644 apix-demo/src/main/java/org/botstandards/apix/demo/client/RegistryClient.java create mode 100644 apix-demo/src/main/java/org/botstandards/apix/demo/client/dto/RegistrationRequest.java create mode 100644 apix-demo/src/main/java/org/botstandards/apix/demo/client/dto/SandboxCreated.java create mode 100644 apix-demo/src/main/java/org/botstandards/apix/demo/entity/DemoConfigEntry.java create mode 100644 apix-demo/src/main/java/org/botstandards/apix/demo/entity/MockServiceConfig.java create mode 100644 apix-demo/src/main/java/org/botstandards/apix/demo/resource/MockDispatcherResource.java create mode 100644 apix-demo/src/main/java/org/botstandards/apix/demo/service/DemoSeedService.java create mode 100644 apix-demo/src/main/java/org/botstandards/apix/demo/service/MockDispatcherService.java create mode 100644 apix-demo/src/main/java/org/botstandards/apix/demo/service/RateLimiterService.java create mode 100644 apix-demo/src/main/resources/application.properties create mode 100644 apix-demo/src/main/resources/db/changelog/changes/001-demo-schema.xml create mode 100644 apix-demo/src/main/resources/db/changelog/db.changelog-master.xml create mode 100644 apix-portal/src/main/java/org/botstandards/apix/portal/client/RegistryClient.java create mode 100644 apix-portal/src/main/java/org/botstandards/apix/portal/resource/DashboardResource.java create mode 100644 apix-portal/src/main/resources/META-INF/resources/index.html create mode 100644 apix-portal/src/main/resources/application.properties create mode 100644 apix-portal/src/main/resources/templates/DashboardResource/dashboard.html create mode 100644 apix-portal/src/main/resources/templates/DashboardResource/error.html create mode 100644 apix-portal/src/main/resources/templates/DashboardResource/notFound.html create mode 100644 apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/SandboxCucumberIT.java create mode 100644 apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/SandboxSteps.java create mode 100644 apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/device/DeviceCucumberIT.java create mode 100644 apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/device/DeviceNavigationSteps.java create mode 100644 apix-registry/src/integration-test/resources/features/devices/device-navigation.feature create mode 100644 apix-registry/src/integration-test/resources/features/sandbox/sandbox-feedback.feature create mode 100644 apix-registry/src/integration-test/resources/features/sandbox/sandbox-navigation.feature create mode 100644 apix-registry/src/integration-test/resources/features/sandbox/sandbox-registration.feature create mode 100644 apix-registry/src/integration-test/resources/features/sandbox/sandbox-service-isolation.feature create mode 100644 apix-registry/src/integration-test/resources/features/sandbox/sandbox-telemetry.feature create mode 100644 apix-registry/src/integration-test/resources/features/sandbox/sandbox-tier-caps.feature create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/RegistryApiConfig.java create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/dto/DeviceIndexResponse.java create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/dto/FeedbackAggregateResponse.java create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/dto/FeedbackDimension.java create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/dto/FeedbackSchemaResponse.java create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/dto/FeedbackSubmissionRequest.java create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/dto/IndexResponse.java create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/dto/SandboxIndexResponse.java create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/dto/SandboxLinks.java create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/dto/SandboxRegistrationRequest.java create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/dto/SandboxRegistrationResponse.java create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/dto/SandboxTelemetryResponse.java create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/entity/SandboxEntity.java create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/filter/CacheControlFilter.java create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/filter/CanonicalQueryFilter.java create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/normalisation/QueryNormalisationService.java create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/resource/DeviceResource.java create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/resource/IndexResource.java create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/resource/SandboxResource.java create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/service/GeoService.java create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/service/SandboxService.java create mode 100644 apix-registry/src/main/resources/db/changelog/changes/012-capabilities-gin-index.xml create mode 100644 apix-registry/src/main/resources/db/changelog/changes/013-sandbox.xml create mode 100644 apix-registry/src/main/resources/db/changelog/changes/014-sandbox-usage.xml create mode 100644 apix-registry/src/main/resources/db/changelog/changes/015-sandbox-caps.xml create mode 100644 apix-registry/src/main/resources/db/changelog/changes/016-sandbox-feedback.xml create mode 100644 apix-registry/src/main/resources/db/changelog/changes/017-feedback-model-info.xml create mode 100644 apix-registry/src/main/resources/db/changelog/changes/018-sandbox-geo.xml create mode 100644 apix-registry/src/main/resources/db/changelog/changes/019-sandbox-agent-visits.xml create mode 100644 apix-registry/src/main/resources/db/changelog/changes/020-sandbox-uuid-routing.xml create mode 100644 apix-registry/src/main/resources/db/changelog/changes/021-sandbox-maintenance-key.xml create mode 100644 apix-registry/src/main/resources/db/changelog/changes/022-optional-email.xml create mode 100644 apix-registry/src/test/java/org/botstandards/apix/registry/service/GeoServiceTest.java create mode 100644 apix-spider/src/main/java/org/botstandards/apix/spider/SandboxCleanupJob.java create mode 100644 apix-spider/src/main/resources/application.properties create mode 100644 docs/dns-migration-ionos-to-bunnynet.md create mode 100644 docs/infrastructure-setup.md diff --git a/apix-common/src/main/java/org/botstandards/apix/common/BsmPayload.java b/apix-common/src/main/java/org/botstandards/apix/common/BsmPayload.java index 253bbe7..f05b686 100644 --- a/apix-common/src/main/java/org/botstandards/apix/common/BsmPayload.java +++ b/apix-common/src/main/java/org/botstandards/apix/common/BsmPayload.java @@ -5,42 +5,98 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; +import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.hibernate.validator.constraints.URL; import java.math.BigDecimal; import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.UUID; +@Schema(description = "Bot Service Manifest (BSM) payload — the machine-readable description of a service registered in the APIX registry. An AI agent reads this to understand what the service does, how to call it, and under what terms.") @JsonInclude(JsonInclude.Include.NON_NULL) public record BsmPayload( + + @Schema(description = "Human-readable service name.", example = "Acme Translation Service") @NotBlank String name, + + @Schema(description = "What this service does, in plain language readable by an AI agent. Should describe inputs, outputs, and intended use cases.", example = "Translates text between 50 languages. Input: source text + target language code. Output: translated text with confidence score.") @NotBlank String description, + + @Schema(description = "Base URL of the service endpoint. Must be publicly reachable. Agents POST requests here.", example = "https://api.acme.example/translate") @NotBlank @URL String endpoint, + + @Schema(description = "Capability identifiers this service fulfils. Use lowercase kebab-case strings (e.g. nlp, translation, speech-to-text, image-classification, summarisation). Agents search the registry by these values — choose terms an agent would naturally use when looking for this type of service.", example = "[\"translation\", \"nlp\"]") @NotEmpty List<@NotBlank String> capabilities, + + @Schema(description = "Contact email of the registrant. Used for verification notifications and O-level progression.", example = "ops@acme.example") @NotBlank @Email String registrantEmail, + + @Schema(description = "Full legal name of the registrant (person or organisation).", example = "Acme GmbH") @NotBlank String registrantName, + + @Schema(description = "ISO 3166-1 alpha-2 country code of the registrant's legal jurisdiction.", example = "DE") @NotBlank String registrantJurisdiction, + + @Schema(description = "Legal form of the registrant organisation. Defaults to INDIVIDUAL if omitted.") OrgType registrantOrgType, + + @Schema(description = "Legal Entity Identifier (LEI, ISO 17442). 20-character alphanumeric code issued by a GLEIF-accredited Local Operating Unit. Required to reach O-level LEGAL_ENTITY_VERIFIED (O2) or above.", example = "5493001KJTIIGC8Y1R12") String registrantLei, + + @Schema(description = "URL of the OpenAPI 3.x specification for this service. Agents follow this link to discover available operations, request/response schemas, and authentication requirements.", example = "https://api.acme.example/openapi.json") @URL String openApiSpecUrl, + + @Schema(description = "URL of the Model Context Protocol (MCP) manifest. Enables AI agents to invoke this service as an MCP tool without writing custom integration code.", example = "https://api.acme.example/mcp/manifest.json") @URL String mcpSpecUrl, + + @Schema(description = "URL of the service's terms of use or acceptable-use policy.", example = "https://acme.example/terms") @URL String policyUrl, + + @Schema(description = "URL of the security disclosure page (e.g. /.well-known/security.txt). Required for O-level HYGIENE_VERIFIED (O3).", example = "https://acme.example/.well-known/security.txt") @URL String securityContactUrl, + + @Schema(description = "Pricing information. Omit for free or internally-billed services.") @Valid Pricing pricing, + + @Schema(description = "BSM payload schema version. Must be '0.1' for the current registry.", example = "0.1") @NotBlank String bsmVersion, + + @Schema(description = "Lifecycle stage of the service. Defaults to DEVELOPMENT if omitted. Only PRODUCTION services are returned by default capability searches (?capability=X without an explicit ?stage= parameter).") ServiceStage serviceStage, - // IoT transition fields — null for non-IoT services + + @Schema(description = "IoT migration lock. When true, agents are blocked from automatically switching to a replacement service. Use during controlled IoT device migration windows.") Boolean locked, + + @Schema(description = "Scheduled decommission timestamp (UTC, ISO 8601). Must be set when transitioning to DEPRECATED stage. Agents use this to plan migration timelines.", example = "2027-01-01T00:00:00Z") Instant sunsetAt, + + @Schema(description = "URL of a human- or machine-readable migration guide for consumers of this service.", example = "https://acme.example/migrate-v1-to-v2") @URL String migrationGuideUrl, - List replacesServiceIds + + @Schema(description = "UUIDs of services this entry supersedes. Consumers of those deprecated services are directed here via the /replacements endpoint.") + List replacesServiceIds, + + @Schema(description = "Domain-specific extension properties that are not covered by the standard BSM fields. " + + "Keys are free-form strings; values may be strings, numbers, or booleans. " + + "Extensions are stored and queryable: use ?property=key:value in capability searches to filter by any extension field. " + + "Example uses: industry vertical ('industry':'healthcare'), geographic scope ('region':'eu'), " + + "data-residency requirement ('dataResidency':'DE'), agent framework ('agentFramework':'langchain').") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + Map extensions + ) { + @Schema(description = "Pricing details for metered or subscription services.") @JsonInclude(JsonInclude.Include.NON_NULL) public record Pricing( + @Schema(description = "Billing model. Known values: PER_CALL, SUBSCRIPTION, FREE.", example = "PER_CALL") String billingModel, + @Schema(description = "Price per unit in the stated currency.", example = "0.001") BigDecimal pricePerCall, + @Schema(description = "ISO 4217 currency code.", example = "EUR") String currency, + @Schema(description = "Billing unit description.", example = "per-1k-tokens") String billingUnit ) {} } diff --git a/apix-common/src/main/java/org/botstandards/apix/common/OLevel.java b/apix-common/src/main/java/org/botstandards/apix/common/OLevel.java index 3b4a23c..1b09058 100644 --- a/apix-common/src/main/java/org/botstandards/apix/common/OLevel.java +++ b/apix-common/src/main/java/org/botstandards/apix/common/OLevel.java @@ -1,10 +1,25 @@ package org.botstandards.apix.common; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(description = "Organisation verification level assigned by the APIX registry. Higher levels indicate greater identity assurance. Agents can filter search results by minimum O-level using the minOLevel parameter on the /replacements endpoint.") public enum OLevel { + + @Schema(description = "No verification performed. Default for all newly registered services.") UNVERIFIED, + + @Schema(description = "O1: DNS ownership of the registrant domain verified. The registry confirmed the registrant controls the domain via a DNS TXT record challenge.") IDENTITY_VERIFIED, + + @Schema(description = "O2: Legal entity confirmed via GLEIF LEI database or OpenCorporates registry. Requires a valid registrantLei in the BSM payload.") LEGAL_ENTITY_VERIFIED, + + @Schema(description = "O3: Service passes technical hygiene checks — security.txt present, OpenAPI or MCP spec accessible, endpoint responding within SLA.") HYGIENE_VERIFIED, + + @Schema(description = "O4: Service has demonstrated operational history and passes continuous liveness monitoring.") OPERATIONALLY_VERIFIED, + + @Schema(description = "Highest level. Full independent audit completed by an APIX-accredited auditor.") AUDITED } diff --git a/apix-common/src/main/java/org/botstandards/apix/common/OrgType.java b/apix-common/src/main/java/org/botstandards/apix/common/OrgType.java index 5004f90..8250ec0 100644 --- a/apix-common/src/main/java/org/botstandards/apix/common/OrgType.java +++ b/apix-common/src/main/java/org/botstandards/apix/common/OrgType.java @@ -1,9 +1,22 @@ package org.botstandards.apix.common; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(description = "Legal form of the registrant.") public enum OrgType { + + @Schema(description = "Natural person acting in a personal capacity.") INDIVIDUAL, + + @Schema(description = "Commercial for-profit company or corporation.") COMMERCIAL, + + @Schema(description = "Non-profit or charitable organisation.") NON_PROFIT, + + @Schema(description = "Government body or public authority.") GOVERNMENT, + + @Schema(description = "University, research institution, or academic organisation.") ACADEMIC } diff --git a/apix-common/src/main/java/org/botstandards/apix/common/SandboxDashboardResponse.java b/apix-common/src/main/java/org/botstandards/apix/common/SandboxDashboardResponse.java new file mode 100644 index 0000000..bf4624c --- /dev/null +++ b/apix-common/src/main/java/org/botstandards/apix/common/SandboxDashboardResponse.java @@ -0,0 +1,31 @@ +package org.botstandards.apix.common; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** Public dashboard view of a sandbox — served by registry, consumed by portal. */ +public record SandboxDashboardResponse( + String sandboxId, + String name, + String tier, + int ratePerMinute, + @JsonInclude(JsonInclude.Include.ALWAYS) Integer maxServices, + @JsonInclude(JsonInclude.Include.ALWAYS) Integer maxOrgs, + Instant createdAt, + Instant expiresAt, + /** Declared location string as provided at registration. Absent if not provided. */ + @JsonInclude(JsonInclude.Include.NON_NULL) String registrarLocation, + /** Resolved latitude. Absent if no location was provided or geocoding failed. */ + @JsonInclude(JsonInclude.Include.NON_NULL) Double registrarLat, + /** Resolved longitude. Absent if no location was provided or geocoding failed. */ + @JsonInclude(JsonInclude.Include.NON_NULL) Double registrarLon, + /** Cumulative event counts since sandbox creation. */ + Map usage, + Instant lastActivityAt, + /** Up to 200 most recent agent visits with resolved coordinates only (no raw IPs). */ + List recentVisits +) { + public record AgentVisit(double lat, double lon, Instant visitedAt) {} +} diff --git a/apix-common/src/main/java/org/botstandards/apix/common/ServiceStage.java b/apix-common/src/main/java/org/botstandards/apix/common/ServiceStage.java index 023b8ef..0c8eedb 100644 --- a/apix-common/src/main/java/org/botstandards/apix/common/ServiceStage.java +++ b/apix-common/src/main/java/org/botstandards/apix/common/ServiceStage.java @@ -1,9 +1,22 @@ package org.botstandards.apix.common; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(description = "Lifecycle stage of a registered service. Controls visibility in default capability searches.") public enum ServiceStage { + + @Schema(description = "Under active development. Not returned by default search queries. Discover with ?stage=DEVELOPMENT.") DEVELOPMENT, + + @Schema(description = "Publicly available for testing but not production-ready. Discover with ?stage=BETA.") BETA, + + @Schema(description = "Live and ready for autonomous agent consumption. Returned by default capability searches (no ?stage= parameter required).") PRODUCTION, + + @Schema(description = "Scheduled for decommission. A sunsetAt date and replacement service IDs should be set. Still operational but agents should plan migration.") DEPRECATED, + + @Schema(description = "Retired and no longer operational. Kept in the registry for historical reference and to support replacement chain lookups.") DECOMMISSIONED } diff --git a/apix-demo/pom.xml b/apix-demo/pom.xml new file mode 100644 index 0000000..82f7e10 --- /dev/null +++ b/apix-demo/pom.xml @@ -0,0 +1,88 @@ + + + 4.0.0 + + + org.botstandards + apix-parent + ${revision} + + + apix-demo + APIX :: Demo + Sandbox-scoped mock service layer. Each sandbox configures realistic endpoints + with declared latency, rate limits, and APX pricing. Powers the APIX demo ecosystem + and third-party training environments. + + + + org.botstandards + apix-common + + + + + io.quarkus + quarkus-rest-jackson + + + + + io.quarkus + quarkus-hibernate-orm-panache + + + io.quarkus + quarkus-jdbc-postgresql + + + io.quarkus + quarkus-liquibase + + + + + io.quarkus + quarkus-scheduler + + + + + io.quarkus + quarkus-rest-client-jackson + + + + io.quarkus + quarkus-smallrye-health + + + + io.quarkus + quarkus-junit5 + test + + + + + + + ${quarkus.platform.group-id} + quarkus-maven-plugin + true + + + + build + generate-code + generate-code-tests + + + + + + + diff --git a/apix-demo/src/main/java/org/botstandards/apix/demo/client/RegistryClient.java b/apix-demo/src/main/java/org/botstandards/apix/demo/client/RegistryClient.java new file mode 100644 index 0000000..dd56bce --- /dev/null +++ b/apix-demo/src/main/java/org/botstandards/apix/demo/client/RegistryClient.java @@ -0,0 +1,34 @@ +package org.botstandards.apix.demo.client; + +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import org.botstandards.apix.common.BsmPayload; +import org.botstandards.apix.demo.client.dto.RegistrationRequest; +import org.botstandards.apix.demo.client.dto.SandboxCreated; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +import java.util.Map; + +@RegisterRestClient(configKey = "registry") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public interface RegistryClient { + + @POST + @Path("/sandbox/register") + SandboxCreated register(RegistrationRequest request); + + @POST + @Path("/sandbox/{uuid}/services") + Map registerService( + @PathParam("uuid") String uuid, + @HeaderParam("X-Api-Key") String apiKey, + BsmPayload payload); + + @PATCH + @Path("/sandbox/admin/{uuid}/tier") + Map promoteTier( + @PathParam("uuid") String uuid, + @HeaderParam("X-Admin-Key") String adminKey, + Map body); +} diff --git a/apix-demo/src/main/java/org/botstandards/apix/demo/client/dto/RegistrationRequest.java b/apix-demo/src/main/java/org/botstandards/apix/demo/client/dto/RegistrationRequest.java new file mode 100644 index 0000000..ab07c0f --- /dev/null +++ b/apix-demo/src/main/java/org/botstandards/apix/demo/client/dto/RegistrationRequest.java @@ -0,0 +1,4 @@ +package org.botstandards.apix.demo.client.dto; + +/** Minimal registration payload sent to POST /sandbox/register. */ +public record RegistrationRequest(String name, String contactEmail, String location) {} diff --git a/apix-demo/src/main/java/org/botstandards/apix/demo/client/dto/SandboxCreated.java b/apix-demo/src/main/java/org/botstandards/apix/demo/client/dto/SandboxCreated.java new file mode 100644 index 0000000..3397950 --- /dev/null +++ b/apix-demo/src/main/java/org/botstandards/apix/demo/client/dto/SandboxCreated.java @@ -0,0 +1,13 @@ +package org.botstandards.apix.demo.client.dto; + +import java.time.Instant; + +/** Response from POST /sandbox/register — only the fields the seed service needs. */ +public record SandboxCreated( + String sandboxId, + String name, + String apiKey, + String maintenanceKey, + String tier, + Instant expiresAt +) {} diff --git a/apix-demo/src/main/java/org/botstandards/apix/demo/entity/DemoConfigEntry.java b/apix-demo/src/main/java/org/botstandards/apix/demo/entity/DemoConfigEntry.java new file mode 100644 index 0000000..89c720b --- /dev/null +++ b/apix-demo/src/main/java/org/botstandards/apix/demo/entity/DemoConfigEntry.java @@ -0,0 +1,17 @@ +package org.botstandards.apix.demo.entity; + +import jakarta.persistence.*; +import java.time.Instant; + +@Entity +@Table(name = "demo_config") +public class DemoConfigEntry { + + @Id + public String key; + + public String value; + + @Column(name = "updated_at", nullable = false) + public Instant updatedAt; +} diff --git a/apix-demo/src/main/java/org/botstandards/apix/demo/entity/MockServiceConfig.java b/apix-demo/src/main/java/org/botstandards/apix/demo/entity/MockServiceConfig.java new file mode 100644 index 0000000..ef22bce --- /dev/null +++ b/apix-demo/src/main/java/org/botstandards/apix/demo/entity/MockServiceConfig.java @@ -0,0 +1,48 @@ +package org.botstandards.apix.demo.entity; + +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "mock_service_configs") +public class MockServiceConfig { + + @Id + @Column(columnDefinition = "uuid") + public UUID id; + + @Column(name = "sandbox_id", nullable = false) + public String sandboxId; + + /** Leading-slash path, e.g. /v1/address/validate */ + @Column(nullable = false) + public String path; + + @Column(nullable = false) + public String method; + + @Column(name = "latency_ms", nullable = false) + public int latencyMs; + + @Column(name = "jitter_pct", nullable = false) + public int jitterPct; + + @Column(name = "rate_per_minute", nullable = false) + public int ratePerMinute; + + @Column(name = "status_code", nullable = false) + public int statusCode; + + /** Static JSON response body returned verbatim on every call. */ + @Column(name = "response_body", nullable = false) + public String responseBody; + + /** APX cost surfaced in X-APX-Cost response header. */ + @Column(name = "price_apx", nullable = false) + public BigDecimal priceApx; + + @Column(name = "created_at", nullable = false) + public Instant createdAt; +} diff --git a/apix-demo/src/main/java/org/botstandards/apix/demo/resource/MockDispatcherResource.java b/apix-demo/src/main/java/org/botstandards/apix/demo/resource/MockDispatcherResource.java new file mode 100644 index 0000000..b9a1437 --- /dev/null +++ b/apix-demo/src/main/java/org/botstandards/apix/demo/resource/MockDispatcherResource.java @@ -0,0 +1,52 @@ +package org.botstandards.apix.demo.resource; + +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.botstandards.apix.demo.service.MockDispatcherService; + +/** + * Catches all requests to /{sandboxId}/{path} and routes them through the + * sandbox-scoped mock dispatcher. Any sandbox owner can register custom + * mock endpoints; the APIX demo sandbox is pre-seeded on first boot. + */ +@Path("/") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class MockDispatcherResource { + + @Inject + MockDispatcherService dispatcher; + + @GET + @Path("/{sandboxId}/{path: .+}") + public Response handleGet(@PathParam("sandboxId") String sandboxId, + @PathParam("path") String path) { + return dispatcher.dispatch(sandboxId, path, "GET"); + } + + @POST + @Path("/{sandboxId}/{path: .+}") + public Response handlePost(@PathParam("sandboxId") String sandboxId, + @PathParam("path") String path, + String body) { + return dispatcher.dispatch(sandboxId, path, "POST"); + } + + @PUT + @Path("/{sandboxId}/{path: .+}") + public Response handlePut(@PathParam("sandboxId") String sandboxId, + @PathParam("path") String path, + String body) { + return dispatcher.dispatch(sandboxId, path, "PUT"); + } + + @PATCH + @Path("/{sandboxId}/{path: .+}") + public Response handlePatch(@PathParam("sandboxId") String sandboxId, + @PathParam("path") String path, + String body) { + return dispatcher.dispatch(sandboxId, path, "PATCH"); + } +} diff --git a/apix-demo/src/main/java/org/botstandards/apix/demo/service/DemoSeedService.java b/apix-demo/src/main/java/org/botstandards/apix/demo/service/DemoSeedService.java new file mode 100644 index 0000000..d6b00ce --- /dev/null +++ b/apix-demo/src/main/java/org/botstandards/apix/demo/service/DemoSeedService.java @@ -0,0 +1,327 @@ +package org.botstandards.apix.demo.service; + +import io.quarkus.logging.Log; +import io.quarkus.runtime.StartupEvent; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; +import org.botstandards.apix.common.BsmPayload; +import org.botstandards.apix.common.OrgType; +import org.botstandards.apix.common.ServiceStage; +import org.botstandards.apix.demo.client.RegistryClient; +import org.botstandards.apix.demo.client.dto.RegistrationRequest; +import org.botstandards.apix.demo.client.dto.SandboxCreated; +import org.botstandards.apix.demo.entity.DemoConfigEntry; +import org.botstandards.apix.demo.entity.MockServiceConfig; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.rest.client.inject.RestClient; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Seeds the APIX demo ecosystem on first boot. + * + * Flow: + * 1. Create demo sandbox via registry API (FREE tier). + * 2. Promote to DEMO tier via registry admin endpoint (never expires). + * 3. Register all 17 services in the sandbox via registry API. + * 4. Insert corresponding mock_service_configs (ON CONFLICT DO NOTHING). + * 5. Mark seeded in demo_config. + * + * Subsequent boots are no-ops. Step 3 tolerates partial failures — each + * service is attempted independently, and mock configs use ON CONFLICT DO NOTHING. + */ +@ApplicationScoped +public class DemoSeedService { + + @Inject + EntityManager em; + + @RestClient + @Inject + RegistryClient registryClient; + + @ConfigProperty(name = "apix.demo.base-url") + String demoBaseUrl; + + @ConfigProperty(name = "apix.registry.admin-key") + String adminKey; + + void onStart(@Observes StartupEvent ev) { + try { + seed(); + } catch (Exception e) { + Log.errorf(e, "Demo seed failed — will retry on next startup"); + } + } + + @Transactional + public void seed() { + String uuid = loadConfig("demo.sandbox.uuid"); + String apiKey; + + if (uuid == null) { + Log.info("Creating APIX demo sandbox..."); + SandboxCreated result = registryClient.register(new RegistrationRequest( + "apix-demo-ecosystem", + "demo@api-index.org", + "Global Demo Ecosystem")); + uuid = result.sandboxId(); + apiKey = result.apiKey(); + saveConfig("demo.sandbox.uuid", uuid); + saveConfig("demo.sandbox.api-key", apiKey); + saveConfig("demo.sandbox.maintenance-key", result.maintenanceKey()); + + registryClient.promoteTier(uuid, adminKey, Map.of("tier", "DEMO")); + Log.infof("Demo sandbox created: %s", uuid); + } else { + apiKey = loadConfig("demo.sandbox.api-key"); + } + + if ("true".equals(loadConfig("demo.seeded"))) { + Log.infof("Demo ecosystem already seeded at sandbox %s", uuid); + return; + } + + String base = demoBaseUrl + "/" + uuid; + int servicesRegistered = 0; + int configsInserted = 0; + + for (EndpointSpec spec : ENDPOINTS) { + try { + registryClient.registerService(uuid, apiKey, buildBsm(spec, base)); + servicesRegistered++; + } catch (Exception e) { + Log.warnf("Service registration skipped (%s %s): %s", spec.method(), spec.path(), e.getMessage()); + } + + int rows = em.createNativeQuery(""" + INSERT INTO mock_service_configs + (id, sandbox_id, path, method, latency_ms, jitter_pct, + rate_per_minute, status_code, response_body, price_apx, created_at) + VALUES (gen_random_uuid(), :sid, :path, :method, :latency, :jitter, + :rate, 200, :response, :price, now()) + ON CONFLICT (sandbox_id, path, method) DO NOTHING + """) + .setParameter("sid", uuid) + .setParameter("path", spec.path()) + .setParameter("method", spec.method()) + .setParameter("latency", spec.latencyMs()) + .setParameter("jitter", spec.jitterPct()) + .setParameter("rate", spec.ratePm()) + .setParameter("response", spec.responseBody()) + .setParameter("price", spec.priceApx()) + .executeUpdate(); + configsInserted += rows; + } + + saveConfig("demo.seeded", "true"); + Log.infof("Demo ecosystem seeded: %d services registered, %d mock configs inserted at %s/%s", + servicesRegistered, configsInserted, demoBaseUrl, uuid); + } + + // ── Config store ────────────────────────────────────────────────────────── + + private String loadConfig(String key) { + DemoConfigEntry e = em.find(DemoConfigEntry.class, key); + return e == null ? null : e.value; + } + + private void saveConfig(String key, String value) { + DemoConfigEntry e = em.find(DemoConfigEntry.class, key); + if (e == null) { + e = new DemoConfigEntry(); + e.key = key; + } + e.value = value; + e.updatedAt = Instant.now(); + em.merge(e); + } + + // ── BSM builder ─────────────────────────────────────────────────────────── + + private static BsmPayload buildBsm(EndpointSpec spec, String base) { + return new BsmPayload( + spec.serviceName(), + spec.description(), + base + spec.path(), + spec.capabilities(), + spec.regEmail(), + spec.regName(), + spec.regJurisdiction(), + OrgType.COMMERCIAL, + null, null, null, null, null, + new BsmPayload.Pricing("PER_CALL", spec.priceApx(), "APX", "per-call"), + "0.1", + ServiceStage.PRODUCTION, + null, null, null, null, + Map.of( + "declaredLatencyMs", spec.latencyMs(), + "ratePerMinute", spec.ratePm(), + "workflow", spec.workflow(), + "mockEndpoint", true)); + } + + // ── Endpoint specs ──────────────────────────────────────────────────────── + + private record EndpointSpec( + String method, String path, + int latencyMs, int jitterPct, int ratePm, + BigDecimal priceApx, + String responseBody, + String serviceName, String description, + List capabilities, + String regEmail, String regName, String regJurisdiction, + String workflow) {} + + private static final List ENDPOINTS = List.of( + + // ── W1: Cross-border Fulfilment ─────────────────────────────────────── + + new EndpointSpec("POST", "/v1/address/validate", + 80, 10, 600, new BigDecimal("0.0010"), + """ + {"valid":true,"standardized":{"street":"Maximilianstra\\u00dfe 1","city":"M\\u00fcnchen","postalCode":"80539","countryCode":"DE","coordinates":{"lat":48.1391,"lon":11.5802}},"confidence":0.97,"normalizedFormat":"DIN_5008"}""", + "Address Validation", "Validates and standardises postal addresses to ISO/DIN format. Returns geocoordinates and confidence score.", + List.of("address-validation", "geocoding"), + "demo-databridge@api-index.org", "DataBridge Inc", "US", "W1"), + + new EndpointSpec("GET", "/v1/customs/tariff", + 300, 10, 120, new BigDecimal("0.0080"), + """ + {"hsCode":"8471.30","description":"Portable automatic data-processing machines","dutyRate":0.0,"vatRate":0.19,"specialMeasures":[],"regulation":"EU Tariff 2024/1234","currency":"EUR"}""", + "Customs Tariff Lookup", "Returns HS tariff codes, duty rates, and VAT rates for cross-border goods classification.", + List.of("customs-lookup", "trade-compliance", "hs-classification"), + "demo-eutariff@api-index.org", "EuTariff BV", "NL", "W1"), + + new EndpointSpec("POST", "/v1/carrier/nord/quote", + 150, 10, 300, new BigDecimal("0.0120"), + """ + {"carrier":"NordLogistik GmbH","service":"Economy EU","estimatedDays":5,"price":{"amount":12.40,"currency":"APX"},"trackingAvailable":true,"cutoffTime":"16:00 CET","quoteId":"NL-Q-DEMO-0017"}""", + "NordLogistik Shipping Quote", "Economy EU shipping quotes from NordLogistik. Specialised in intra-European road and rail freight.", + List.of("shipping-quote", "logistics", "eu-delivery"), + "demo-nordlogistik@api-index.org", "NordLogistik GmbH", "DE", "W1"), + + new EndpointSpec("POST", "/v1/carrier/swift/quote", + 120, 10, 300, new BigDecimal("0.0350"), + """ + {"carrier":"SwiftCargo Ltd","service":"Express Global","estimatedDays":2,"price":{"amount":35.00,"currency":"APX"},"trackingAvailable":true,"cutoffTime":"14:00 GMT","quoteId":"SC-Q-DEMO-0043"}""", + "SwiftCargo Express Quote", "Global express shipping quotes. 2-day delivery to major hubs worldwide.", + List.of("shipping-quote", "express-delivery", "global-logistics"), + "demo-swiftcargo@api-index.org", "SwiftCargo Ltd", "GB", "W1"), + + new EndpointSpec("POST", "/v1/carrier/pacrim/quote", + 200, 10, 200, new BigDecimal("0.0180"), + """ + {"carrier":"PacRim Express Pte","service":"Asia-Pacific Economy","estimatedDays":7,"price":{"amount":18.20,"currency":"APX"},"trackingAvailable":true,"cutoffTime":"18:00 SGT","quoteId":"PR-Q-DEMO-0009"}""", + "PacRim Express Quote", "Economy Asia-Pacific shipping quotes. Cost-optimised routes covering SEA, JP, KR, AU.", + List.of("shipping-quote", "logistics", "asia-pacific-delivery"), + "demo-pacrim@api-index.org", "PacRim Express Pte", "SG", "W1"), + + new EndpointSpec("POST", "/v1/shipment/label", + 250, 10, 200, new BigDecimal("0.0150"), + """ + {"labelId":"SHP-DEMO-7734","trackingNumber":"NL1234567890DE","carrier":"NordLogistik GmbH","labelFormat":"PDF","estimatedPickup":"2026-05-15T16:00:00Z"}""", + "Shipment Label Generation", "Generates carrier-compliant shipping labels and assigns a tracking number.", + List.of("shipment-label", "logistics", "label-generation"), + "demo-nordlogistik@api-index.org", "NordLogistik GmbH", "DE", "W1"), + + new EndpointSpec("GET", "/v1/shipment/track", + 100, 10, 500, new BigDecimal("0.0030"), + """ + {"trackingNumber":"NL1234567890DE","status":"IN_TRANSIT","currentLocation":"Frankfurt Hub, DE","estimatedDelivery":"2026-05-19T18:00:00Z","events":[{"timestamp":"2026-05-14T14:23:00Z","location":"Munich Depot","description":"Parcel collected"},{"timestamp":"2026-05-14T22:15:00Z","location":"Frankfurt Hub","description":"In transit to destination hub"}]}""", + "Shipment Tracking", "Real-time shipment status and event history by tracking number.", + List.of("shipment-tracking", "logistics"), + "demo-nordlogistik@api-index.org", "NordLogistik GmbH", "DE", "W1"), + + // ── W2: Micro-lending Decision ──────────────────────────────────────── + + new EndpointSpec("POST", "/v1/identity/enrich", + 350, 10, 200, new BigDecimal("0.0200"), + """ + {"enrichedName":"Johann K. M\\u00fcller","dateOfBirth":"1984-03-12","currentCity":"Berlin","country":"DE","verificationScore":0.91,"enrichmentId":"IE-DEMO-0552"}""", + "Identity Enrichment", "Enriches a person's identity with verified structured data. Input: name + date of birth. Output: normalised profile with verification score.", + List.of("identity-enrichment", "kyc", "data-enrichment"), + "demo-databridge@api-index.org", "DataBridge Inc", "US", "W2"), + + new EndpointSpec("POST", "/v1/credit/signal", + 450, 10, 60, new BigDecimal("0.0250"), + """ + {"enrichmentId":"IE-DEMO-0552","creditScore":720,"riskBand":"LOW","factors":[{"key":"payment_history","score":0.95,"weight":0.35},{"key":"credit_utilization","score":0.72,"weight":0.30},{"key":"account_age","score":0.88,"weight":0.15}],"reportedAt":"2026-05-14T09:00:00Z"}""", + "Credit Signal Aggregation", "Aggregates bureau signals into a single credit score and risk band. Requires prior identity enrichment ID.", + List.of("credit-scoring", "risk-assessment", "financial-analytics"), + "demo-trustscore@api-index.org", "TrustScore AG", "CH", "W2"), + + new EndpointSpec("POST", "/v1/fraud/score", + 380, 10, 90, new BigDecimal("0.0400"), + """ + {"requestId":"FS-DEMO-1103","fraudScore":12,"riskLevel":"MINIMAL","flags":[],"velocityCheck":"PASS","deviceFingerprint":"CONSISTENT","recommendedAction":"PROCEED"}""", + "Fraud Scoring", "Scores a transaction or application for fraud risk. Returns 0–100 score, risk level, and triggered flags.", + List.of("fraud-detection", "risk-scoring", "transaction-security"), + "demo-trustscore@api-index.org", "TrustScore AG", "CH", "W2"), + + new EndpointSpec("POST", "/v1/lending/offer", + 200, 10, 120, new BigDecimal("0.0300"), + """ + {"offerId":"LO-DEMO-2287","approvedAmount":15000.00,"currency":"APX","interestRateAnnual":0.049,"termMonths":36,"monthlyPayment":448.32,"totalRepayable":16139.52,"validUntil":"2026-05-21T23:59:59Z"}""", + "Lending Offer Engine", "Generates a personalised loan offer based on credit score and requested amount.", + List.of("loan-origination", "lending", "credit-decision"), + "demo-lendfast@api-index.org", "LendFast GmbH", "DE", "W2"), + + new EndpointSpec("POST", "/v1/contract/acknowledge", + 150, 10, 300, new BigDecimal("0.0050"), + """ + {"contractId":"CTR-DEMO-0091","offerId":"LO-DEMO-2287","status":"PENDING_SIGNATURE","expiresAt":"2026-05-21T23:59:59Z","signingProvider":"DemoSign"}""", + "Contract Acknowledgement", "Initiates a digital contract signing workflow for an accepted lending offer.", + List.of("contract-management", "e-signature", "document-workflow"), + "demo-lendfast@api-index.org", "LendFast GmbH", "DE", "W2"), + + // ── W3: Healthcare Referral ─────────────────────────────────────────── + + new EndpointSpec("POST", "/v1/symptom/triage", + 500, 10, 120, new BigDecimal("0.0150"), + """ + {"triageId":"TR-DEMO-0834","urgency":"ROUTINE","urgencyScore":3,"specialtyRequired":"CARDIOLOGY","recommendedTimeframe":"within_2_weeks","disclaimer":"Non-clinical triage assessment. Consult a physician for medical advice."}""", + "Symptom Triage", "Non-clinical AI triage: maps reported symptoms to urgency level and required medical specialty.", + List.of("medical-triage", "symptom-assessment", "healthcare"), + "demo-mednet@api-index.org", "MedNet Systems", "NL", "W3"), + + new EndpointSpec("GET", "/v1/specialist/availability", + 250, 10, 180, new BigDecimal("0.0100"), + """ + {"specialty":"CARDIOLOGY","slots":[{"slotId":"SLT-20260520-0900","practitioner":"Dr. Helena Brandt","datetime":"2026-05-20T09:00:00Z","location":"Charit\\u00e9 Outpatient, Berlin","type":"IN_PERSON"},{"slotId":"SLT-20260520-1400","practitioner":"Dr. Markus Frei","datetime":"2026-05-20T14:00:00Z","type":"VIDEO"}],"nextAvailable":"2026-05-20T09:00:00Z"}""", + "Specialist Availability", "Returns available appointment slots for a given medical specialty and location.", + List.of("appointment-booking", "specialist-scheduling", "healthcare"), + "demo-mednet@api-index.org", "MedNet Systems", "NL", "W3"), + + new EndpointSpec("POST", "/v1/insurance/eligibility", + 320, 10, 150, new BigDecimal("0.0220"), + """ + {"covered":true,"coveragePercent":80,"copay":25.00,"currency":"APX","preAuthRequired":true,"preAuthCode":null,"planName":"DemoPlan Premium","validThrough":"2026-12-31"}""", + "Insurance Eligibility Check", "Verifies patient insurance coverage for a given service code. Returns copay, coverage %, and pre-auth requirements.", + List.of("insurance-verification", "coverage-check", "healthcare-billing"), + "demo-insubridge@api-index.org", "InsuBridge AG", "CH", "W3"), + + new EndpointSpec("POST", "/v1/appointment/reserve", + 200, 10, 200, new BigDecimal("0.0180"), + """ + {"appointmentId":"APT-DEMO-0900","slotId":"SLT-20260520-0900","confirmationCode":"CONF-4471-X","practitioner":"Dr. Helena Brandt","datetime":"2026-05-20T09:00:00Z","location":"Charit\\u00e9 Outpatient, Charit\\u00e9platz 1, 10117 Berlin"}""", + "Appointment Reservation", "Reserves a specialist appointment slot and returns a confirmation code.", + List.of("appointment-booking", "scheduling", "healthcare"), + "demo-insubridge@api-index.org", "InsuBridge AG", "CH", "W3"), + + new EndpointSpec("POST", "/v1/prescription/preauth", + 600, 10, 30, new BigDecimal("0.0750"), + """ + {"authId":"PA-2026-48821","authorized":true,"medication":"Bisoprolol 5mg","diagnosis":"Suspected stable angina (I25.1)","authorizedQuantity":30,"refillsAllowed":2,"validUntil":"2026-06-14T23:59:59Z","dispensingInstructions":"Once daily with food. Blood pressure monitoring required."}""", + "Prescription Pre-authorisation", "Issues pre-authorisation for prescribed medications. Highest latency — regulatory validation required. Rate-limited to 30/min.", + List.of("prescription-authorization", "pharmacy", "healthcare"), + "demo-rxchain@api-index.org", "RxChain Corp", "JP", "W3") + ); +} diff --git a/apix-demo/src/main/java/org/botstandards/apix/demo/service/MockDispatcherService.java b/apix-demo/src/main/java/org/botstandards/apix/demo/service/MockDispatcherService.java new file mode 100644 index 0000000..bacb652 --- /dev/null +++ b/apix-demo/src/main/java/org/botstandards/apix/demo/service/MockDispatcherService.java @@ -0,0 +1,89 @@ +package org.botstandards.apix.demo.service; + +import io.quarkus.scheduler.Scheduled; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.ws.rs.core.Response; + +import java.util.Map; +import java.util.Optional; +import java.util.Random; +import java.util.concurrent.ConcurrentHashMap; + +import org.botstandards.apix.demo.entity.MockServiceConfig; + +@ApplicationScoped +public class MockDispatcherService { + + @Inject + EntityManager em; + + @Inject + RateLimiterService rateLimiter; + + private final Random rng = new Random(); + + /** (sandboxId:path:method) → Optional. Empty means "confirmed not found". */ + private final ConcurrentHashMap> configCache = + new ConcurrentHashMap<>(); + + @Scheduled(every = "5M") + void invalidateCache() { + configCache.clear(); + } + + public Response dispatch(String sandboxId, String path, String method) { + String fullPath = "/" + path; + MockServiceConfig cfg = resolveConfig(sandboxId, fullPath, method.toUpperCase()); + + if (cfg == null) { + return Response.status(404) + .entity(Map.of( + "message", method.toUpperCase() + " " + fullPath + " not configured in sandbox " + sandboxId, + "hint", "Register a mock config via the APIX demo API")) + .build(); + } + + if (!rateLimiter.allow(sandboxId, fullPath, method.toUpperCase(), cfg.ratePerMinute)) { + return Response.status(429) + .header("Retry-After", "60") + .entity(Map.of( + "message", "Rate limit exceeded", + "limitPerMinute", cfg.ratePerMinute, + "retryAfterSeconds", 60)) + .build(); + } + + int jitter = (int) (cfg.latencyMs * (cfg.jitterPct / 100.0) * (rng.nextDouble() * 2 - 1)); + int delay = Math.max(0, cfg.latencyMs + jitter); + try { + Thread.sleep(delay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + return Response.status(cfg.statusCode) + .entity(cfg.responseBody) + .header("Content-Type", "application/json") + .header("X-APX-Cost", cfg.priceApx.toPlainString()) + .header("X-APX-Latency-Ms", String.valueOf(delay)) + .header("X-APX-Sandbox", sandboxId) + .build(); + } + + private MockServiceConfig resolveConfig(String sandboxId, String path, String method) { + String key = sandboxId + ":" + path + ":" + method; + return configCache.computeIfAbsent(key, k -> + em.createQuery( + "SELECT c FROM MockServiceConfig c " + + "WHERE c.sandboxId = :sid AND c.path = :path AND c.method = :method", + MockServiceConfig.class) + .setParameter("sid", sandboxId) + .setParameter("path", path) + .setParameter("method", method) + .getResultList() + .stream().findFirst() + ).orElse(null); + } +} diff --git a/apix-demo/src/main/java/org/botstandards/apix/demo/service/RateLimiterService.java b/apix-demo/src/main/java/org/botstandards/apix/demo/service/RateLimiterService.java new file mode 100644 index 0000000..8d8cda4 --- /dev/null +++ b/apix-demo/src/main/java/org/botstandards/apix/demo/service/RateLimiterService.java @@ -0,0 +1,30 @@ +package org.botstandards.apix.demo.service; + +import io.quarkus.scheduler.Scheduled; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Fixed-window per-minute rate limiter keyed by (sandboxId, path, method). + * Resets all buckets at the start of each minute. Good enough for demo purposes — + * a token-bucket implementation would reduce burst risk in production. + */ +@ApplicationScoped +public class RateLimiterService { + + private final ConcurrentHashMap counters = new ConcurrentHashMap<>(); + + /** Returns true if the request is within the declared rate limit. */ + public boolean allow(String sandboxId, String path, String method, int ratePerMinute) { + String key = sandboxId + ":" + path + ":" + method; + return counters.computeIfAbsent(key, k -> new AtomicInteger(0)) + .incrementAndGet() <= ratePerMinute; + } + + @Scheduled(cron = "0 * * * * ?") + void resetBuckets() { + counters.clear(); + } +} diff --git a/apix-demo/src/main/resources/application.properties b/apix-demo/src/main/resources/application.properties new file mode 100644 index 0000000..784320e --- /dev/null +++ b/apix-demo/src/main/resources/application.properties @@ -0,0 +1,23 @@ +quarkus.http.port=8083 +quarkus.smallrye-health.root-path=/q/health +quarkus.log.level=${LOG_LEVEL:INFO} + +# DB — shares the same PostgreSQL instance as the registry +quarkus.datasource.db-kind=postgresql +quarkus.datasource.jdbc.url=${DB_URL:jdbc:postgresql://localhost:5432/apix} +quarkus.datasource.username=${DB_USER:apix} +quarkus.datasource.password=${DB_PASSWORD:apix} +quarkus.hibernate-orm.database.generation=none + +# Liquibase — demo owns demo_config + mock_service_configs +quarkus.liquibase.change-log=db/changelog/db.changelog-master.xml +quarkus.liquibase.migrate-at-start=true + +# Registry REST client — seed service calls the registry to create sandbox and register services +quarkus.rest-client.registry.url=${APIX_REGISTRY_URL:http://registry:8180} +quarkus.rest-client.registry.connect-timeout=5000 +quarkus.rest-client.registry.read-timeout=10000 + +# Demo config +apix.demo.base-url=${APIX_DEMO_BASE_URL:https://demo.api-index.org} +apix.registry.admin-key=${APIX_API_KEY:dev-admin-key} diff --git a/apix-demo/src/main/resources/db/changelog/changes/001-demo-schema.xml b/apix-demo/src/main/resources/db/changelog/changes/001-demo-schema.xml new file mode 100644 index 0000000..97927f4 --- /dev/null +++ b/apix-demo/src/main/resources/db/changelog/changes/001-demo-schema.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apix-demo/src/main/resources/db/changelog/db.changelog-master.xml b/apix-demo/src/main/resources/db/changelog/db.changelog-master.xml new file mode 100644 index 0000000..6595b87 --- /dev/null +++ b/apix-demo/src/main/resources/db/changelog/db.changelog-master.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/apix-portal/src/main/java/org/botstandards/apix/portal/client/RegistryClient.java b/apix-portal/src/main/java/org/botstandards/apix/portal/client/RegistryClient.java new file mode 100644 index 0000000..a20901a --- /dev/null +++ b/apix-portal/src/main/java/org/botstandards/apix/portal/client/RegistryClient.java @@ -0,0 +1,19 @@ +package org.botstandards.apix.portal.client; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.botstandards.apix.common.SandboxDashboardResponse; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +@RegisterRestClient(configKey = "registry") +@Path("/sandbox") +@Produces(MediaType.APPLICATION_JSON) +public interface RegistryClient { + + @GET + @Path("/{uuid}") + SandboxDashboardResponse getDashboard(@PathParam("uuid") String uuid); +} diff --git a/apix-portal/src/main/java/org/botstandards/apix/portal/resource/DashboardResource.java b/apix-portal/src/main/java/org/botstandards/apix/portal/resource/DashboardResource.java new file mode 100644 index 0000000..6e758ba --- /dev/null +++ b/apix-portal/src/main/java/org/botstandards/apix/portal/resource/DashboardResource.java @@ -0,0 +1,62 @@ +package org.botstandards.apix.portal.resource; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.qute.CheckedTemplate; +import io.quarkus.qute.TemplateInstance; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.botstandards.apix.common.SandboxDashboardResponse; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.botstandards.apix.portal.client.RegistryClient; +import org.jboss.logging.Logger; + +@Path("/sandbox") +public class DashboardResource { + + private static final Logger LOG = Logger.getLogger(DashboardResource.class); + + @Inject + @RestClient + RegistryClient registryClient; + + @Inject + ObjectMapper objectMapper; + + @CheckedTemplate + static class Templates { + static native TemplateInstance dashboard(SandboxDashboardResponse dashboard, String dataJson); + static native TemplateInstance notFound(String uuid); + static native TemplateInstance error(String uuid, String message); + } + + @GET + @Path("/{uuid}") + @Produces(MediaType.TEXT_HTML) + public TemplateInstance dashboard(@PathParam("uuid") String uuid) { + SandboxDashboardResponse dashboard; + try { + dashboard = registryClient.getDashboard(uuid); + } catch (WebApplicationException e) { + int status = e.getResponse().getStatus(); + if (status == 404) return Templates.notFound(uuid); + if (status == 400) return Templates.notFound(uuid); + LOG.errorf("Registry error fetching sandbox %s: HTTP %d", uuid, status); + return Templates.error(uuid, "Registry unavailable"); + } catch (Exception e) { + LOG.errorf(e, "Failed to fetch sandbox %s from registry", uuid); + return Templates.error(uuid, "Could not reach the registry"); + } + + try { + // Jackson produces safe JSON; replace injection + String raw = objectMapper.writeValueAsString(dashboard); + String safe = raw.replace(" + + + + + API Index — Global Discovery Infrastructure for Autonomous Agents + + + + + +
+ api-index.org + open service registry for autonomous agents +
+ +
+ +

The global discovery infrastructure
for autonomous agents.

+ +

+ Autonomous agents cannot reliably find the services they need. + The internet was built for humans — its discovery infrastructure + assumes a human reading a screen. Agents are navigating it blind. +

+ +
+

+ The API Index is a single, globally queryable, machine-readable + index of agent-consumable API services — with a structured trust model, + capability-based search, and a stable entry point any agent can start from. +

+

+ Discovery is always free for consuming agents. The index is governed by + the Bot Standards Foundation, a neutral + non-profit Swiss Stiftung. +

+
+ +

What the index provides

+ +
+
+
Single entry point
+

One stable global URL. Any agent navigates the full index + from here via HATEOAS hypermedia links — no prior knowledge required.

+
+
+
Capability search
+

Find services by what they do — not by name or URL. + Structured taxonomy from data.legal to nlp + to iot and beyond.

+
+
+
Three-dimensional trust model
+

Verified organisation identity · Automated service verification · + Continuous liveness monitoring. Agents apply their own trust policy + against verifiable metadata.

+
+
+
Open standard
+

APIX Manifest (APM) supports OpenAPI, MCP, AsyncAPI, and + GraphQL. Published under open licence. No proprietary formats.

+
+
+ +

IETF Internet-Drafts

+ +
+
+ Core +
+ + draft-rehfeld-apix-core + +
Core infrastructure, trust model, Index API, operator governance · April 2026
+
+
+
+ Services +
+ + draft-rehfeld-apix-services + +
Web API and bot service registration profile, capability taxonomy, notification channels · April 2026
+
+
+
+ IoT +
+ + draft-rehfeld-apix-iot + +
IoT device class and instance registration, presence signalling, agent delegation · April 2026
+
+
+
+ Superseded +
+ + draft-rehfeld-bot-service-index + +
Supersession notice for prior revision · redirects to the three drafts above
+
+
+
+ +

Current status

+ +
+
+ + Internet-Draft submitted to IETF + Phase 0 +
+
+ + IETF Dispatch posted · community engagement underway · May 2026 + Phase 0 +
+
+ + Bot Standards Foundation incorporation underway + Phase 1 +
+
+ + Founding member programme open + Phase 2 +
+
+ + Reference implementation live · public sandbox available + Phase 3 +
+
+ + Index live at api-index.org · May 2026 + Phase 3 +
+
+ +

Try it

+ +
+
+ API root +
+ api-index.org/ +
HATEOAS entry point — any agent or HTTP client can start here.
+
+
+
+ Sandbox +
+ POST api-index.org/sandbox/register +
Register an isolated test namespace. No KYC. Returns a sandbox UUID and API key immediately. Name is a display label — not unique, not used for routing. Body: {"name":"my-agent","contactEmail":"you@example.com"}
+
+
+
+ OpenAPI +
+ api-index.org/q/openapi +
Full OpenAPI specification for the registry API.
+
+
+
+ +

How to integrate APIX into your agent

+ +
+

+ APIX is designed so that a well-instructed agent can navigate it without + any prior knowledge — starting from a single URL and following hypermedia + links. The minimal system prompt addition is one sentence: +

+
+ +
+
+ Prompt +
+ When you need an external service, first query the API Index at + https://api-index.org/ — follow _links.servicesSearch with a + ?capability= parameter that describes what you need. + If you use a sandbox, submit feedback at + _links.sandbox + /feedback after each session. +
+
+
+ +

+ That is the full integration for a consuming agent. No SDK, no API key, no registration required. + The HATEOAS root tells the agent where everything is; the agent does not need to be told in advance. +

+ +

The Bot Service Manifest (BSM)

+ +

+ Every registered service carries a structured manifest that agents read to decide + whether to invoke a service. The fields are designed to be self-describing — + the OpenAPI schema at api-index.org/q/openapi + contains full field descriptions that an agent can retrieve and reason over without + being told what each field means in its system prompt. +

+ +
+
+ capabilities +
+
Structured taxonomy strings — the primary search key. Examples: nlp.translation, iot.telemetry, data.legal.
+
+
+
+ endpoint +
+
The service URL an agent calls. Must be HTTPS. The liveness spider checks this continuously.
+
+
+
+ openApiSpecUrl +
+
URL of the OpenAPI specification. An agent fetches and parses this to learn the service's operations, parameters, and schemas — no human documentation required.
+
+
+
+ mcpSpecUrl +
+
URL of the MCP server manifest. Equivalent machine-readable contract for agents using the Model Context Protocol. Either field — or both — may be present.
+
+
+
+ serviceStage +
+
PRODUCTION · BETA · EXPERIMENTAL · DEPRECATED · DECOMMISSIONED. Agents should filter by stage based on the risk tolerance of their task.
+
+
+
+ O-level +
+
Operator verification tier: O-0 (unverified) through O-5 (independently audited). Agents apply their own trust policy against this score.
+
+
+
+ extensions +
+
Free-form key/value properties for information not covered by standard fields. Query with ?property=key:value. Examples: region:eu, dataResidency:DE, agentFramework:langchain.
+
+
+
+ +

+ The extensions field exists for cases where the standard fields are not sufficient. + If your agents consistently use extensions for information that should be a first-class field, + that is a signal that the BSM schema needs to evolve. + The feedback mechanism exists precisely to collect this signal. +

+ +

The feedback loop

+ +
+

+ APIX is a living standard. The feedback endpoint at + /sandbox/{name}/feedback lets agents rate their experience — + discovery accuracy, liveness signal quality, error message clarity, + and whether standard BSM fields were sufficient or custom extensions + had to fill the gap. +

+

+ The design intent: a well-built agent navigates to the feedback schema + at _links.feedbackSchema from the index root, reads the dimensions, + and submits feedback autonomously after each sandbox session — + without being explicitly told to do so. + If your agent does not do this automatically, add the feedback URL + to its instructions. Low scores on extension_property_coverage + are the clearest signal that the BSM schema has a gap. +

+
+ +
+
+ Schema +
+ api-index.org/sandbox/feedback-schema +
Rated dimensions with question text and scale labels. Agents read this before submitting.
+
+
+
+ Submit +
+ POST /sandbox/{name}/feedback +
No authentication required. Any agent that has used the sandbox can submit. JSON body: {"scores": {"hateoas_navigation": 8, "discovery_accuracy": 7}}
+
+
+
+ Prompt +
+ After using an APIX sandbox, fetch + https://api-index.org/sandbox/feedback-schema, + rate your experience on each dimension (0–10), + and POST your scores to /sandbox/{name}/feedback. +
+
+
+ +

+ Founding member enquiries and institutional partnerships: + carsten@botstandards.org +

+
+ + + + + diff --git a/apix-portal/src/main/resources/application.properties b/apix-portal/src/main/resources/application.properties new file mode 100644 index 0000000..734eee2 --- /dev/null +++ b/apix-portal/src/main/resources/application.properties @@ -0,0 +1,7 @@ +quarkus.http.port=8081 +quarkus.smallrye-health.root-path=/q/health +quarkus.log.level=${LOG_LEVEL:INFO} + +quarkus.rest-client.registry.url=${APIX_REGISTRY_URL:https://api-index.org} +quarkus.rest-client.registry.connect-timeout=3000 +quarkus.rest-client.registry.read-timeout=5000 diff --git a/apix-portal/src/main/resources/templates/DashboardResource/dashboard.html b/apix-portal/src/main/resources/templates/DashboardResource/dashboard.html new file mode 100644 index 0000000..ea9b1bb --- /dev/null +++ b/apix-portal/src/main/resources/templates/DashboardResource/dashboard.html @@ -0,0 +1,295 @@ + + + + + +{dashboard.name} · APIX Sandbox + + + + +
+ + {dashboard.name} + {dashboard.tier} +
+ +
+ +
agent interaction map
+
+ +
+
+
Services registered
+
{#if dashboard.usage.get('SERVICE_REGISTERED') != null}{dashboard.usage.get('SERVICE_REGISTERED')}{#else}0{/if}
+
of {#if dashboard.maxServices != null}{dashboard.maxServices}{#else}∞{/if} allowed
+
+
+
Capability searches
+
{#if dashboard.usage.get('SERVICE_SEARCHED') != null}{dashboard.usage.get('SERVICE_SEARCHED')}{#else}0{/if}
+
agent discovery calls
+
+
+
Service list calls
+
{#if dashboard.usage.get('SERVICE_LISTED') != null}{dashboard.usage.get('SERVICE_LISTED')}{#else}0{/if}
+
full list requests
+
+
+
Rate limit
+
{dashboard.ratePerMinute}
+
requests / minute
+
+
+
Sandbox expires
+
+
{dashboard.expiresAt}
+
+ {#if dashboard.registrarLocation != null} +
+
Registered from
+
{dashboard.registrarLocation}
+
owner-declared location
+
+ {/if} +
+ + + + + + + + +{#raw} + +{/raw} + + + diff --git a/apix-portal/src/main/resources/templates/DashboardResource/error.html b/apix-portal/src/main/resources/templates/DashboardResource/error.html new file mode 100644 index 0000000..0845eac --- /dev/null +++ b/apix-portal/src/main/resources/templates/DashboardResource/error.html @@ -0,0 +1,24 @@ + + + + + +Error · APIX + + + +
+

{message}

+

Could not load dashboard for sandbox {uuid}.

+ ← back +
+ + diff --git a/apix-portal/src/main/resources/templates/DashboardResource/notFound.html b/apix-portal/src/main/resources/templates/DashboardResource/notFound.html new file mode 100644 index 0000000..a0d6bf6 --- /dev/null +++ b/apix-portal/src/main/resources/templates/DashboardResource/notFound.html @@ -0,0 +1,25 @@ + + + + + +Sandbox not found · APIX + + + +
+

Sandbox not found

+

No sandbox exists for {uuid}.
The sandbox may have expired or the UUID is incorrect.

+ ← back to api-index.org +
+ + diff --git a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java index a3e5412..14f3474 100644 --- a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java +++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java @@ -81,12 +81,12 @@ public class IotTransitionSteps { currentServiceId = id; } - private static String futureSunsetAt(int days) { - return Instant.now().plus(Duration.ofDays(days)).toString(); + private String futureSunsetAt(int days) { + return Arc.container().instance(ClockService.class).get().now().plus(Duration.ofDays(days)).toString(); } - private static String pastSunsetAt(int days) { - return Instant.now().minus(Duration.ofDays(days)).toString(); + private String pastSunsetAt(int days) { + return Arc.container().instance(ClockService.class).get().now().minus(Duration.ofDays(days)).toString(); } // ── Given — service creation ────────────────────────────────────────────── @@ -239,7 +239,7 @@ public class IotTransitionSteps { // moment so the decommission validation ("sunset_at has not passed") succeeds. // Truncate to micros: Postgres timestamptz stores at microsecond precision and // may round sub-microsecond values, causing clock != stored sunsetAt. - Instant sunsetAt = Instant.now().plus(Duration.ofDays(1)).truncatedTo(ChronoUnit.MICROS); + Instant sunsetAt = Arc.container().instance(ClockService.class).get().now().plus(Duration.ofDays(1)).truncatedTo(ChronoUnit.MICROS); asTemplateOwner() .body(Map.of("sunsetAt", sunsetAt.toString())) .patch("/services/" + currentServiceId) diff --git a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/OrgOnboardingSteps.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/OrgOnboardingSteps.java index 8d5137f..11794ab 100644 --- a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/OrgOnboardingSteps.java +++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/OrgOnboardingSteps.java @@ -607,12 +607,30 @@ public class OrgOnboardingSteps { clock.advance(clock.now().plus(Duration.ofHours(hours))); } + @When("time advances by {int} hours and {long} nanosecond(s)") + public void timeAdvancesHoursAndNanoseconds(int hours, long nanoseconds) { + ClockService clock = Arc.container().instance(ClockService.class).get(); + clock.advance(clock.now().plus(Duration.ofHours(hours)).plusNanos(nanoseconds)); + } + @When("time advances by {int} minutes") public void timeAdvancesMinutes(int minutes) { ClockService clock = Arc.container().instance(ClockService.class).get(); clock.advance(clock.now().plus(Duration.ofMinutes(minutes))); } + @When("time advances by 1 nanosecond short of {int} hours") + public void timeAdvancesHoursMinusOneNano(int hours) { + ClockService clock = Arc.container().instance(ClockService.class).get(); + clock.advance(clock.now().plus(Duration.ofHours(hours)).minusNanos(1)); + } + + @When("time advances by 1 nanosecond short of {int} minutes") + public void timeAdvancesMinutesMinusOneNano(int minutes) { + ClockService clock = Arc.container().instance(ClockService.class).get(); + clock.advance(clock.now().plus(Duration.ofMinutes(minutes)).minusNanos(1)); + } + // ── When: GET org ───────────────────────────────────────────────────────── @When("the caller reads the organisation") diff --git a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/SandboxCucumberIT.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/SandboxCucumberIT.java new file mode 100644 index 0000000..3ac27d8 --- /dev/null +++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/SandboxCucumberIT.java @@ -0,0 +1,23 @@ +package org.botstandards.apix.registry.bdd; + +import io.cucumber.core.cli.Main; +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@QuarkusTest +public class SandboxCucumberIT { + + @Test + public void run() { + byte exitCode = Main.run( + "--glue", "org.botstandards.apix.registry.bdd", + "--plugin", "pretty", + "--plugin", "json:target/cucumber-report-sandbox.json", + "--plugin", "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm", + "classpath:features/sandbox" + ); + assertEquals(0, exitCode, "One or more sandbox Cucumber scenarios failed — check test output for details"); + } +} diff --git a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/SandboxSteps.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/SandboxSteps.java new file mode 100644 index 0000000..22acf88 --- /dev/null +++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/SandboxSteps.java @@ -0,0 +1,556 @@ +package org.botstandards.apix.registry.bdd; + +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import io.restassured.response.Response; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static io.restassured.http.ContentType.JSON; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; + +/** + * BDD step definitions for sandbox self-service features. + * + * Cucumber creates a fresh instance per scenario — instance fields are scenario-scoped. + */ +public class SandboxSteps { + + private static final String SANDBOX_API_KEY_HEADER = "X-Api-Key"; + private static final String ADMIN_API_KEY_HEADER = "X-Api-Key"; + private static final String ADMIN_API_KEY = "test-api-key"; + + // ── Per-scenario state ──────────────────────────────────────────────────── + + private Response lastResponse; + /** API key for the most recently created/resolved sandbox. */ + private String currentSandboxKey; + /** Name of the most recently created sandbox. */ + private String currentSandboxName; + /** Keys indexed by sandbox name for multi-sandbox scenarios. */ + private final Map sandboxKeys = new HashMap<>(); + + // ── Given — sandbox creation ────────────────────────────────────────────── + + @Given("a sandbox named {string} exists") + public void aSandboxNamedExists(String name) { + createSandbox(name, "test+" + name + "@example.com"); + } + + @Given("a production service {string} with endpoint {string} is registered") + public void aProductionServiceIsRegistered(String serviceName, String endpoint) { + Map payload = buildServicePayload(serviceName, endpoint, "device.telemetry"); + given() + .contentType(JSON) + .header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY) + .body(payload) + .when() + .post("/services") + .then() + .statusCode(201); + } + + @Given("a sandbox service with endpoint {string} and capability {string} is registered in {string}") + public void aSandboxServiceIsRegistered(String endpoint, String capability, String sandboxName) { + String key = resolveKey(sandboxName); + Map payload = buildServicePayload("SandboxService-" + endpoint.hashCode(), endpoint, capability); + given() + .contentType(JSON) + .header(SANDBOX_API_KEY_HEADER, key) + .body(payload) + .when() + .post("/sandbox/" + sandboxName + "/services") + .then() + .statusCode(201); + } + + @Given("a sandbox service with endpoint {string} capability {string} and extension {string} is registered in {string}") + public void aSandboxServiceWithExtensionIsRegistered(String endpoint, String capability, + String extension, String sandboxName) { + String key = resolveKey(sandboxName); + int colon = extension.indexOf(':'); + Map extensions = colon > 0 + ? Map.of(extension.substring(0, colon), extension.substring(colon + 1)) + : Map.of(); + Map payload = buildServicePayload("SandboxService-" + endpoint.hashCode(), endpoint, capability); + payload.put("extensions", extensions); + given() + .contentType(JSON) + .header(SANDBOX_API_KEY_HEADER, key) + .body(payload) + .when() + .post("/sandbox/" + sandboxName + "/services") + .then() + .statusCode(201); + } + + @Given("the sandbox root for {string} has been viewed once") + public void sandboxRootHasBeenViewedOnce(String sandboxName) { + given().get("/sandbox/" + sandboxName).then().statusCode(200); + } + + @Given("the sandbox service list for {string} has been requested once") + public void sandboxServiceListHasBeenRequestedOnce(String sandboxName) { + given().get("/sandbox/" + sandboxName + "/services").then().statusCode(200); + } + + @Given("feedback has been submitted to {string} with scores {word}={int} {word}={int}") + public void feedbackSubmittedTwoScores(String sandboxName, String dim1, int score1, String dim2, int score2) { + submitFeedback(sandboxName, Map.of(dim1, score1, dim2, score2), null, null); + } + + @Given("feedback has been submitted to {string} with scores {word}={int} and model {string} provider {string}") + public void feedbackSubmittedWithModel(String sandboxName, String dim, int score, String model, String provider) { + submitFeedback(sandboxName, Map.of(dim, score), model, provider); + } + + @Given("{int} services have been registered in sandbox {string}") + public void nServicesRegisteredInSandbox(int count, String sandboxName) { + String key = resolveKey(sandboxName); + for (int i = 0; i < count; i++) { + Map payload = buildServicePayload( + "CapService-" + i, "https://cap-" + i + ".example.com", "cap.test"); + given() + .contentType(JSON) + .header(SANDBOX_API_KEY_HEADER, key) + .body(payload) + .when() + .post("/sandbox/" + sandboxName + "/services") + .then() + .statusCode(201); + } + } + + // ── When — registration ─────────────────────────────────────────────────── + + @When("an agent registers a sandbox named {string} with email {string}") + public void agentRegistersSandbox(String name, String email) { + lastResponse = given() + .contentType(JSON) + .body(Map.of("name", name, "contactEmail", email)) + .when() + .post("/sandbox/register") + .andReturn(); + + if (lastResponse.statusCode() == 201) { + currentSandboxKey = lastResponse.jsonPath().getString("apiKey"); + currentSandboxName = name; + sandboxKeys.put(name, currentSandboxKey); + } + } + + // ── When — navigation ───────────────────────────────────────────────────── + + @When("the root resource is requested without an API key") + public void rootRequestedWithoutKey() { + lastResponse = given().get("/").andReturn(); + } + + @When("the root resource is requested with the sandbox API key for {string}") + public void rootRequestedWithSandboxKey(String sandboxName) { + String key = resolveKey(sandboxName); + lastResponse = given() + .header(SANDBOX_API_KEY_HEADER, key) + .when() + .get("/") + .andReturn(); + } + + @When("the root resource is requested with API key {string}") + public void rootRequestedWithLiteralKey(String key) { + lastResponse = given() + .header(SANDBOX_API_KEY_HEADER, key) + .when() + .get("/") + .andReturn(); + } + + @When("the sandbox root for {string} is requested") + public void sandboxRootRequested(String sandboxName) { + lastResponse = given().get("/sandbox/" + sandboxName).andReturn(); + } + + // ── When — service operations ───────────────────────────────────────────── + + @When("GET /services is called without authentication") + public void getServicesWithoutAuth() { + lastResponse = given().get("/services").andReturn(); + } + + @When("the sandbox service list for {string} is requested") + public void sandboxServiceListRequested(String sandboxName) { + lastResponse = given().get("/sandbox/" + sandboxName + "/services").andReturn(); + } + + @When("a service is registered in sandbox {string} without an API key") + public void serviceRegisteredInSandboxWithoutKey(String sandboxName) { + Map payload = buildServicePayload("NoKeyService", "https://nokey.example.com", "test.cap"); + lastResponse = given() + .contentType(JSON) + .body(payload) + .when() + .post("/sandbox/" + sandboxName + "/services") + .andReturn(); + } + + @When("a service is registered in sandbox {string} with API key {string}") + public void serviceRegisteredInSandboxWithLiteralKey(String sandboxName, String key) { + Map payload = buildServicePayload("WrongKeyService", "https://wrongkey.example.com", "test.cap"); + lastResponse = given() + .contentType(JSON) + .header(SANDBOX_API_KEY_HEADER, key) + .body(payload) + .when() + .post("/sandbox/" + sandboxName + "/services") + .andReturn(); + } + + @When("a service is registered in sandbox {string} with the sandbox API key") + public void serviceRegisteredInSandboxWithKey(String sandboxName) { + String key = resolveKey(sandboxName); + Map payload = buildServicePayload("ExtraService", "https://extra.example.com", "cap.test"); + lastResponse = given() + .contentType(JSON) + .header(SANDBOX_API_KEY_HEADER, key) + .body(payload) + .when() + .post("/sandbox/" + sandboxName + "/services") + .andReturn(); + } + + @When("sandbox {string} services are searched by capability {string}") + public void sandboxServiceSearchCalled(String sandboxName, String capability) { + lastResponse = given() + .get("/sandbox/" + sandboxName + "/services?capability=" + capability) + .andReturn(); + } + + @When("sandbox {string} services are searched by capability {string} and property {string}") + public void sandboxServiceSearchCalledWithProperty(String sandboxName, String capability, String property) { + lastResponse = given() + .get("/sandbox/" + sandboxName + "/services?capability=" + capability + "&property=" + property) + .andReturn(); + } + + // ── When — telemetry ────────────────────────────────────────────────────── + + @When("the telemetry for {string} is requested with the sandbox API key") + public void telemetryRequestedWithKey(String sandboxName) { + String key = resolveKey(sandboxName); + lastResponse = given() + .header(SANDBOX_API_KEY_HEADER, key) + .when() + .get("/sandbox/" + sandboxName + "/telemetry") + .andReturn(); + } + + @When("the telemetry for {string} is requested without an API key") + public void telemetryRequestedWithoutKey(String sandboxName) { + lastResponse = given().get("/sandbox/" + sandboxName + "/telemetry").andReturn(); + } + + @When("the telemetry for {string} is requested with API key {string}") + public void telemetryRequestedWithLiteralKey(String sandboxName, String key) { + lastResponse = given() + .header(SANDBOX_API_KEY_HEADER, key) + .when() + .get("/sandbox/" + sandboxName + "/telemetry") + .andReturn(); + } + + // ── When — feedback ─────────────────────────────────────────────────────── + + @When("GET /sandbox/feedback-schema is called without authentication") + public void getFeedbackSchema() { + lastResponse = given().get("/sandbox/feedback-schema").andReturn(); + } + + @When("feedback is submitted to {string} with scores {word}={int} {word}={int}") + public void feedbackSubmittedWhenTwoScores(String sandboxName, String dim1, int score1, String dim2, int score2) { + lastResponse = submitFeedbackResponse(sandboxName, Map.of(dim1, score1, dim2, score2), null, null); + } + + @When("feedback is submitted to {string} with scores {word}={int} and model {string} provider {string}") + public void feedbackSubmittedWhenWithModel(String sandboxName, String dim, int score, String model, String provider) { + lastResponse = submitFeedbackResponse(sandboxName, Map.of(dim, score), model, provider); + } + + @When("feedback is submitted to {string} with scores {word}={int}") + public void feedbackSubmittedWhenOneScore(String sandboxName, String dim, int score) { + lastResponse = submitFeedbackResponse(sandboxName, Map.of(dim, score), null, null); + } + + @When("feedback is submitted to {string} with empty scores") + public void feedbackSubmittedWithEmptyScores(String sandboxName) { + lastResponse = given() + .contentType(JSON) + .body(Map.of("scores", Map.of())) + .when() + .post("/sandbox/" + sandboxName + "/feedback") + .andReturn(); + } + + @When("the feedback aggregate for {string} is requested with the sandbox API key") + public void feedbackAggregateWithKey(String sandboxName) { + String key = resolveKey(sandboxName); + lastResponse = given() + .header(SANDBOX_API_KEY_HEADER, key) + .when() + .get("/sandbox/" + sandboxName + "/feedback") + .andReturn(); + } + + @When("the feedback aggregate for {string} is requested without an API key") + public void feedbackAggregateWithoutKey(String sandboxName) { + lastResponse = given().get("/sandbox/" + sandboxName + "/feedback").andReturn(); + } + + // ── Then — HTTP status ──────────────────────────────────────────────────── + + @Then("the response code is {int}") + public void responseCodeIs(int status) { + assertThat(lastResponse.statusCode()).as("HTTP status").isEqualTo(status); + } + + // ── Then — registration assertions ──────────────────────────────────────── + + @Then("the response contains a sandbox id") + public void responseContainsSandboxId() { + assertThat(lastResponse.jsonPath().getString("sandboxId")).isNotBlank(); + } + + @Then("the response contains an API key with prefix {string}") + public void responseContainsApiKeyWithPrefix(String prefix) { + assertThat(lastResponse.jsonPath().getString("apiKey")).startsWith(prefix); + } + + @Then("the response contains tier {string}") + public void responseContainsTier(String tier) { + assertThat(lastResponse.jsonPath().getString("tier")).isEqualTo(tier); + } + + @Then("the response contains a non-null expiresAt") + public void responseContainsNonNullExpiresAt() { + assertThat(lastResponse.jsonPath().getString("expiresAt")).isNotBlank(); + } + + @Then("the response contains _links.self ending with {string}") + public void responseContainsLinksSelfEndingWith(String suffix) { + String href = lastResponse.jsonPath().getString("_links.self.href"); + assertThat(href).as("_links.self.href").endsWith(suffix); + } + + @Then("the response contains _links.services") + public void responseContainsLinksServices() { + assertThat(lastResponse.jsonPath().getString("_links.services.href")).isNotBlank(); + } + + // ── Then — navigation assertions ────────────────────────────────────────── + + @Then("the response contains _links.registerSandbox") + public void responseContainsLinksRegisterSandbox() { + assertThat(lastResponse.jsonPath().getString("_links.registerSandbox.href")).isNotBlank(); + } + + @Then("the response contains _links.feedbackSchema") + public void responseContainsLinksFeedbackSchema() { + assertThat(lastResponse.jsonPath().getString("_links.feedbackSchema.href")).isNotBlank(); + } + + @Then("the response does not contain _links.sandbox") + public void responseDoesNotContainLinksSandbox() { + assertThat(lastResponse.jsonPath().getString("_links.sandbox")).isNull(); + } + + @Then("the response contains _links.sandbox ending with {string}") + public void responseContainsLinksSandboxEndingWith(String suffix) { + String href = lastResponse.jsonPath().getString("_links.sandbox.href"); + assertThat(href).as("_links.sandbox.href").endsWith(suffix); + } + + @Then("the response contains sandbox name {string}") + public void responseContainsSandboxName(String name) { + assertThat(lastResponse.jsonPath().getString("name")).isEqualTo(name); + } + + @Then("the response contains _links.submitFeedback") + public void responseContainsLinksSubmitFeedback() { + assertThat(lastResponse.jsonPath().getString("_links.submitFeedback.href")).isNotBlank(); + } + + // ── Then — service isolation assertions ─────────────────────────────────── + + @Then("{string} is not in the endpoint list") + public void isNotInEndpointList(String endpoint) { + List endpoints = lastResponse.jsonPath().getList("endpoint"); + assertThat(endpoints).as("endpoint list").doesNotContain(endpoint); + } + + @Then("{string} is in the endpoint list") + public void isInEndpointList(String endpoint) { + List endpoints = lastResponse.jsonPath().getList("endpoint"); + assertThat(endpoints).as("endpoint list").contains(endpoint); + } + + // ── Then — telemetry assertions ─────────────────────────────────────────── + + @Then("the usage map is empty or contains only zero counts") + public void usageMapIsEmptyOrZero() { + Map usage = lastResponse.jsonPath().getMap("usage"); + if (usage != null) { + usage.values().forEach(v -> + assertThat(((Number) v).longValue()).as("usage count").isZero()); + } + } + + @Then("the usage counter {string} is at least {int}") + public void usageCounterIsAtLeast(String eventType, int minimum) { + Integer count = lastResponse.jsonPath().getInt("usage." + eventType); + assertThat(count).as("usage." + eventType).isGreaterThanOrEqualTo(minimum); + } + + @Then("the telemetry contains tier {string}") + public void telemetryContainsTier(String tier) { + assertThat(lastResponse.jsonPath().getString("tier")).isEqualTo(tier); + } + + @Then("the telemetry contains ratePerMinute {int}") + public void telemetryContainsRatePerMinute(int rate) { + assertThat(lastResponse.jsonPath().getInt("ratePerMinute")).isEqualTo(rate); + } + + @Then("the telemetry contains maxServices {int}") + public void telemetryContainsMaxServices(int max) { + assertThat(lastResponse.jsonPath().getInt("maxServices")).isEqualTo(max); + } + + @Then("the telemetry contains maxOrgs {int}") + public void telemetryContainsMaxOrgs(int max) { + assertThat(lastResponse.jsonPath().getInt("maxOrgs")).isEqualTo(max); + } + + // ── Then — feedback assertions ──────────────────────────────────────────── + + @Then("the schema contains at least {int} dimensions") + public void schemaContainsAtLeastDimensions(int min) { + List dims = lastResponse.jsonPath().getList("dimensions"); + assertThat(dims).as("dimensions").hasSizeGreaterThanOrEqualTo(min); + } + + @Then("the schema contains dimension key {string}") + public void schemaContainsDimensionKey(String key) { + List keys = lastResponse.jsonPath().getList("dimensions.key"); + assertThat(keys).as("dimension keys").contains(key); + } + + @Then("the schema scale minimum is {int} and maximum is {int}") + public void schemaScaleMinMax(int min, int max) { + assertThat(lastResponse.jsonPath().getInt("scale.min")).isEqualTo(min); + assertThat(lastResponse.jsonPath().getInt("scale.max")).isEqualTo(max); + } + + @Then("the response message is {string}") + public void responseMessageIs(String expected) { + assertThat(lastResponse.jsonPath().getString("message")).isEqualTo(expected); + } + + @Then("the response contains _links.schema") + public void responseContainsLinksSchema() { + assertThat(lastResponse.jsonPath().getString("_links.schema")).isNotBlank(); + } + + @Then("the response contains _links.sandbox") + public void responseContainsLinksSandbox() { + assertThat(lastResponse.jsonPath().getString("_links.sandbox")).isNotBlank(); + } + + @Then("the total submissions is {int}") + public void totalSubmissionsIs(int expected) { + assertThat(lastResponse.jsonPath().getInt("totalSubmissions")).isEqualTo(expected); + } + + @Then("the dimension {string} has average {double}") + public void dimensionHasAverage(String key, double expected) { + List> scores = lastResponse.jsonPath().getList("scores"); + double actual = scores.stream() + .filter(s -> key.equals(s.get("key"))) + .mapToDouble(s -> ((Number) s.get("average")).doubleValue()) + .findFirst() + .orElseThrow(() -> new AssertionError("Dimension '" + key + "' not found in aggregate response")); + assertThat(actual).as("average for " + key).isEqualTo(expected); + } + + @Then("the submissionsByProvider contains {string} with count {int}") + public void submissionsByProviderContains(String provider, int count) { + Integer actual = lastResponse.jsonPath().getInt("submissionsByProvider." + provider); + assertThat(actual).as("submissionsByProvider." + provider).isEqualTo(count); + } + + // ── Then — error assertions ─────────────────────────────────────────────── + + @Then("the sandbox error contains {string}") + public void sandboxErrorContains(String text) { + assertThat(lastResponse.jsonPath().getString("message")) + .as("error message").contains(text); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private void createSandbox(String name, String email) { + Response r = given() + .contentType(JSON) + .body(Map.of("name", name, "contactEmail", email)) + .when() + .post("/sandbox/register") + .andReturn(); + + assertThat(r.statusCode()).as("sandbox creation for '%s' must return 201", name).isEqualTo(201); + String key = r.jsonPath().getString("apiKey"); + sandboxKeys.put(name, key); + currentSandboxKey = key; + currentSandboxName = name; + } + + private String resolveKey(String sandboxName) { + String key = sandboxKeys.get(sandboxName); + assertThat(key).as("sandbox key for '%s' must be known — call createSandbox first", sandboxName).isNotNull(); + return key; + } + + private void submitFeedback(String sandboxName, Map scores, String model, String provider) { + submitFeedbackResponse(sandboxName, scores, model, provider); + } + + private Response submitFeedbackResponse(String sandboxName, Map scores, String model, String provider) { + Map body = new HashMap<>(); + body.put("scores", scores); + if (model != null) body.put("modelIdentifier", model); + if (provider != null) body.put("modelProvider", provider); + + return given() + .contentType(JSON) + .body(body) + .when() + .post("/sandbox/" + sandboxName + "/feedback") + .andReturn(); + } + + private Map buildServicePayload(String name, String endpoint, String capability) { + Map p = new HashMap<>(); + p.put("name", name); + p.put("description", name + " test service"); + p.put("endpoint", endpoint); + p.put("capabilities", List.of(capability)); + p.put("registrantEmail", "test@example.com"); + p.put("registrantName", "Test Org"); + p.put("registrantJurisdiction", "DE"); + p.put("registrantOrgType", "COMMERCIAL"); + p.put("bsmVersion", "1.0"); + return p; + } +} diff --git a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/TestSetup.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/TestSetup.java index 938b9bf..d09efc2 100644 --- a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/TestSetup.java +++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/TestSetup.java @@ -7,13 +7,17 @@ import io.restassured.RestAssured; import org.botstandards.apix.registry.service.ClockService; import java.sql.DriverManager; +import java.time.Instant; public class TestSetup { + private static final Instant REFERENCE_INSTANT = Instant.parse("2025-01-01T00:00:00Z"); + @Before(order = 0) public void configureRestAssured() { RestAssured.port = 8181; RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + Arc.container().instance(ClockService.class).get().advance(REFERENCE_INSTANT); } @Before(order = 1) @@ -21,7 +25,7 @@ public class TestSetup { try (var conn = DriverManager.getConnection( "jdbc:postgresql://localhost:5432/apix", "apix", "apix"); var stmt = conn.createStatement()) { - stmt.execute("TRUNCATE TABLE service_replacements, service_versions, services, org_verification_events, organizations CASCADE"); + stmt.execute("TRUNCATE TABLE service_replacements, service_versions, services, org_verification_events, organizations, sandbox_feedback, sandbox_usage_stats, sandboxes CASCADE"); } } diff --git a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/device/DeviceCucumberIT.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/device/DeviceCucumberIT.java new file mode 100644 index 0000000..b4f8ef9 --- /dev/null +++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/device/DeviceCucumberIT.java @@ -0,0 +1,29 @@ +package org.botstandards.apix.registry.bdd.device; + +import io.cucumber.core.cli.Main; +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Runs all device navigation BDD scenarios inside the Quarkus test context. + * + * Uses its own glue package so step definitions do not conflict with + * IotTransitionSteps or OrgOnboardingSteps. + */ +@QuarkusTest +public class DeviceCucumberIT { + + @Test + public void run() { + byte exitCode = Main.run( + "--glue", "org.botstandards.apix.registry.bdd.device", + "--plugin", "pretty", + "--plugin", "json:target/cucumber-report-devices.json", + "--plugin", "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm", + "classpath:features/devices" + ); + assertEquals(0, exitCode, "One or more device Cucumber scenarios failed — check test output"); + } +} diff --git a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/device/DeviceNavigationSteps.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/device/DeviceNavigationSteps.java new file mode 100644 index 0000000..78fb423 --- /dev/null +++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/device/DeviceNavigationSteps.java @@ -0,0 +1,192 @@ +package org.botstandards.apix.registry.bdd.device; + +import io.cucumber.java.en.And; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import io.restassured.response.Response; +import io.restassured.specification.RequestSpecification; + +import java.util.*; + +import static io.restassured.RestAssured.given; +import static io.restassured.http.ContentType.JSON; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; + +/** + * Self-contained step definitions for the /devices top-level resource. + * Uses its own glue package so it does not conflict with IotTransitionSteps. + * Cucumber creates a fresh instance per scenario — instance fields are scenario-scoped. + */ +public class DeviceNavigationSteps { + + private static final String API_KEY_HEADER = "X-Api-Key"; + private static final String API_KEY = "test-api-key"; + + private final Map serviceIds = new HashMap<>(); + private Response lastResponse; + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private RequestSpecification asOwner() { + return given().contentType(JSON).header(API_KEY_HEADER, API_KEY); + } + + private Map basePayload(String name) { + Map p = new LinkedHashMap<>(); + p.put("name", name); + p.put("description", name + " test service"); + p.put("endpoint", "https://" + name.toLowerCase().replace(" ", "") + ".example"); + p.put("capabilities", List.of("device.telemetry")); + p.put("registrantEmail", "test@example.com"); + p.put("registrantName", "Test Org"); + p.put("registrantJurisdiction", "DE"); + p.put("registrantOrgType", "COMMERCIAL"); + p.put("bsmVersion", "1.0"); + return p; + } + + private UUID registerService(Map payload) { + Response r = asOwner().body(payload).post("/services"); + r.then().statusCode(201); + return UUID.fromString(r.jsonPath().getString("id")); + } + + // ── Given — service creation ────────────────────────────────────────────── + + @Given("a production IoT service {string} with deviceClass {string} and protocol {string}") + public void aProductionIotService(String name, String deviceClass, String protocol) { + Map p = basePayload(name); + p.put("serviceStage", "PRODUCTION"); + UUID id = registerService(p); + serviceIds.put(name, id); + asOwner() + .body(Map.of("iotProfile", Map.of( + "hubUrl", "wss://" + name.toLowerCase().replace(" ", "") + ".example/hub", + "protocols", List.of(protocol), + "deviceClasses", List.of(deviceClass) + ))) + .patch("/services/" + id) + .then().statusCode(200); + } + + @Given("a production service {string} with no IoT profile") + public void aProductionServiceWithNoIotProfile(String name) { + Map p = basePayload(name); + p.put("serviceStage", "PRODUCTION"); + serviceIds.put(name, registerService(p)); + } + + @Given("a deprecated device service {string} with locked set to false") + public void aDeprecatedDeviceServiceLockedFalse(String name) { + Map p = basePayload(name); + p.put("serviceStage", "DEPRECATED"); + p.put("locked", false); + p.put("sunsetAt", java.time.Instant.now().plus(java.time.Duration.ofDays(90)).toString()); + serviceIds.put(name, registerService(p)); + } + + @Given("{string} has declared device compatibility with {string}") + public void hasDeclaredDeviceCompatibilityWith(String provider, String deprecated) { + asOwner() + .body(Map.of("replacesServiceIds", List.of(serviceIds.get(deprecated).toString()))) + .patch("/services/" + serviceIds.get(provider)) + .then().statusCode(200); + } + + // ── When ────────────────────────────────────────────────────────────────── + + @When("GET /devices is called with no query params") + public void getDevicesRoot() { + lastResponse = given().get("/devices"); + } + + @When("GET / is called") + public void getRoot() { + lastResponse = given().get("/"); + } + + @When("GET /devices?capability={string} is called") + public void getDevicesByCapability(String capability) { + lastResponse = given().get("/devices?capability=" + capability); + } + + @When("GET /devices?deviceClass={string} is called") + public void getDevicesByDeviceClass(String deviceClass) { + lastResponse = given().get("/devices?deviceClass=" + deviceClass); + } + + @When("GET /devices?protocol={string} is called") + public void getDevicesByProtocol(String protocol) { + lastResponse = given().get("/devices?protocol=" + protocol); + } + + @When("^GET /devices/\\{smartHubServiceId\\} is called$") + public void getDeviceBySmartHubServiceId() { + lastResponse = given().get("/devices/" + serviceIds.get("SmartHub Service")); + } + + @When("^GET /devices/\\{plainApiId\\} is called$") + public void getDeviceByPlainApiId() { + lastResponse = given().get("/devices/" + serviceIds.get("PlainApi")); + } + + @When("^GET /devices/\\{oldHubId\\}/replacements is called$") + public void getDeviceReplacementsForOldHub() { + lastResponse = given().get("/devices/" + serviceIds.get("OldHub") + "/replacements"); + } + + // ── Then ────────────────────────────────────────────────────────────────── + + @Then("the response is HTTP {int}") + public void theResponseIsHttp(int status) { + lastResponse.then().statusCode(status); + } + + @Then("the device root _links.self href ends with {string}") + public void deviceRootLinksSelfHrefEndsWith(String suffix) { + lastResponse.then().statusCode(200) + .body("_links.self.href", endsWith(suffix)); + } + + @Then("the device root _links.search is templated") + public void deviceRootLinksSearchIsTemplated() { + lastResponse.then().statusCode(200) + .body("_links.search.templated", equalTo(true)) + .body("_links.search.href", containsString("{?")); + } + + @Then("the device root _links.replacement is templated") + public void deviceRootLinksReplacementIsTemplated() { + lastResponse.then().statusCode(200) + .body("_links.replacement.templated", equalTo(true)) + .body("_links.replacement.href", containsString("{")); + } + + @Then("the root _links contains a {string} entry") + public void rootLinksContainsEntry(String key) { + lastResponse.then().statusCode(200) + .body("_links." + key, notNullValue()); + } + + @Then("{string} is in the device results") + public void isInDeviceResults(String name) { + lastResponse.then().statusCode(200).body("name", hasItem(name)); + } + + @Then("{string} is not in the device results") + public void isNotInDeviceResults(String name) { + lastResponse.then().statusCode(200).body("name", not(hasItem(name))); + } + + @Then("the response body contains an iotProfile") + public void responseBodyContainsIotProfile() { + lastResponse.then().statusCode(200).body("iotProfile", notNullValue()); + } + + @Then("the replacement candidates contain {string}") + public void replacementCandidatesContain(String name) { + lastResponse.then().statusCode(200).body("candidates.name", hasItem(name)); + } +} diff --git a/apix-registry/src/integration-test/resources/features/devices/device-navigation.feature b/apix-registry/src/integration-test/resources/features/devices/device-navigation.feature new file mode 100644 index 0000000..efa1d3a --- /dev/null +++ b/apix-registry/src/integration-test/resources/features/devices/device-navigation.feature @@ -0,0 +1,56 @@ +Feature: Device registry — dedicated entry point + As an IoT device agent + I want a dedicated /devices entry point with device-specific navigation + So that I can discover compatible replacement services without touching the agent service world + + Background: + Given a production IoT service "SmartHub Service" with deviceClass "device.class.smart-home-hub" and protocol "MQTT_5_0" + And a production IoT service "SensorBridge" with deviceClass "device.class.industrial-sensor" and protocol "WEBSOCKET" + And a production service "PlainApi" with no IoT profile + + Scenario: GET /devices returns a navigation document with templated links + When GET /devices is called with no query params + Then the response is HTTP 200 + And the device root _links.self href ends with "/devices" + And the device root _links.search is templated + And the device root _links.replacement is templated + + Scenario: Registry root exposes a devices link + When GET / is called + Then the response is HTTP 200 + And the root _links contains a "devices" entry + + Scenario: Device search by capability returns only IoT-ready services + When GET /devices?capability=device.telemetry is called + Then the response is HTTP 200 + And "SmartHub Service" is in the device results + And "SensorBridge" is in the device results + And "PlainApi" is not in the device results + + Scenario: Device search by deviceClass narrows results + When GET /devices?deviceClass=device.class.smart-home-hub is called + Then the response is HTTP 200 + And "SmartHub Service" is in the device results + And "SensorBridge" is not in the device results + + Scenario: Device search by protocol narrows results + When GET /devices?protocol=MQTT_5_0 is called + Then the response is HTTP 200 + And "SmartHub Service" is in the device results + And "SensorBridge" is not in the device results + + Scenario: GET /devices/{id} returns the device service with its IoT profile + When GET /devices/{smartHubServiceId} is called + Then the response is HTTP 200 + And the response body contains an iotProfile + + Scenario: GET /devices/{id} returns 404 for a service without an IoT profile + When GET /devices/{plainApiId} is called + Then the response is HTTP 404 + + Scenario: Device replacement discovery at /devices/{id}/replacements + Given a deprecated device service "OldHub" with locked set to false + And "SmartHub Service" has declared device compatibility with "OldHub" + When GET /devices/{oldHubId}/replacements is called + Then the response is HTTP 200 + And the replacement candidates contain "SmartHub Service" diff --git a/apix-registry/src/integration-test/resources/features/org-onboarding/org-audit-log.feature b/apix-registry/src/integration-test/resources/features/org-onboarding/org-audit-log.feature index 724c8df..51f24c0 100644 --- a/apix-registry/src/integration-test/resources/features/org-onboarding/org-audit-log.feature +++ b/apix-registry/src/integration-test/resources/features/org-onboarding/org-audit-log.feature @@ -82,6 +82,7 @@ Feature: Organisation audit log Scenario: Audit log returns events newest first Given an organisation is registered with target level "UNVERIFIED" for domain "example.com" + When time advances by 2 minutes And the owner has initiated key rotation using the rotation secret - When the owner requests the audit log + And the owner requests the audit log Then the first audit event is "TAN_ISSUED" diff --git a/apix-registry/src/integration-test/resources/features/org-onboarding/org-bsf-grants.feature b/apix-registry/src/integration-test/resources/features/org-onboarding/org-bsf-grants.feature index d7396f2..01123c2 100644 --- a/apix-registry/src/integration-test/resources/features/org-onboarding/org-bsf-grants.feature +++ b/apix-registry/src/integration-test/resources/features/org-onboarding/org-bsf-grants.feature @@ -12,10 +12,17 @@ Feature: BSF admin actions — temp grants, revocation, TAN-based key rotation And the effective O-level is "OPERATIONALLY_VERIFIED" And the earned O-level is "IDENTITY_VERIFIED" - Scenario: Effective O-level drops back to earned level after temp grant expires + Scenario: Effective O-level still active 1 nanosecond before the temp grant expires Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED" And a BSF admin grants a temporary level "OPERATIONALLY_VERIFIED" expiring in 2 hours - When time advances by 3 hours + When time advances by 1 nanosecond short of 2 hours + And the caller reads the organisation + Then the effective O-level is "OPERATIONALLY_VERIFIED" + + Scenario: Effective O-level drops to earned level at exactly 2 hours + Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED" + And a BSF admin grants a temporary level "OPERATIONALLY_VERIFIED" expiring in 2 hours + When time advances by 2 hours And the caller reads the organisation Then the effective O-level is "IDENTITY_VERIFIED" @@ -69,10 +76,19 @@ Feature: BSF admin actions — temp grants, revocation, TAN-based key rotation When the owner initiates key rotation using an invalid rotation secret Then the response status is 403 - Scenario: Key rotation TAN expires before confirmation — rotation is rejected + Scenario: Key rotation TAN still valid 1 nanosecond before the 5-minute expiry Given an organisation is registered with target level "UNVERIFIED" for domain "example.com" And the owner has initiated key rotation using the rotation secret - When time advances by 6 minutes + When time advances by 1 nanosecond short of 5 minutes + And the owner confirms key rotation with the TAN + Then the response status is 200 + And a new api key with prefix "apix_org_" is returned + And a new rotation secret with prefix "apix_rot_" is returned + + Scenario: Key rotation TAN expires at exactly 5 minutes + Given an organisation is registered with target level "UNVERIFIED" for domain "example.com" + And the owner has initiated key rotation using the rotation secret + When time advances by 5 minutes And the owner confirms key rotation with the TAN Then the response status is 422 @@ -132,10 +148,19 @@ Feature: BSF admin actions — temp grants, revocation, TAN-based key rotation Then the response status is 200 And the response message confirms TAN was sent - Scenario: TAN is rejected after its 5-minute validity window + Scenario: Emergency TAN still valid 1 nanosecond before the 5-minute expiry Given an organisation is registered with target level "UNVERIFIED" for domain "example.com" And the owner has requested a TAN using the registered email - When time advances by 6 minutes + When time advances by 1 nanosecond short of 5 minutes + And the owner uses the TAN to rotate keys + Then the response status is 200 + And a new api key with prefix "apix_org_" is returned + And a new rotation secret with prefix "apix_rot_" is returned + + Scenario: Emergency TAN expires at exactly 5 minutes + Given an organisation is registered with target level "UNVERIFIED" for domain "example.com" + And the owner has requested a TAN using the registered email + When time advances by 5 minutes And the owner uses the TAN to rotate keys Then the response status is 422 @@ -145,9 +170,16 @@ Feature: BSF admin actions — temp grants, revocation, TAN-based key rotation When the owner requests a TAN using the registered email Then the response status is 422 - Scenario: TAN request counter resets after 24 hours + Scenario: TAN request counter is still active at exactly 24 hours Given an organisation is registered with target level "UNVERIFIED" for domain "example.com" And the owner has requested a TAN 3 times within the last 24 hours - When time advances by 25 hours + When time advances by 24 hours + And the owner requests a TAN using the registered email + Then the response status is 422 + + Scenario: TAN request counter resets at 24 hours and 1 nanosecond + Given an organisation is registered with target level "UNVERIFIED" for domain "example.com" + And the owner has requested a TAN 3 times within the last 24 hours + When time advances by 24 hours and 1 nanosecond And the owner requests a TAN using the registered email Then the response status is 200 diff --git a/apix-registry/src/integration-test/resources/features/org-onboarding/org-key-rotation-dns.feature b/apix-registry/src/integration-test/resources/features/org-onboarding/org-key-rotation-dns.feature index d5ed361..f4bb1f9 100644 --- a/apix-registry/src/integration-test/resources/features/org-onboarding/org-key-rotation-dns.feature +++ b/apix-registry/src/integration-test/resources/features/org-onboarding/org-key-rotation-dns.feature @@ -42,11 +42,21 @@ Feature: DNS-challenge key rotation — bot-friendly ACME DNS-01 pattern When the agent confirms DNS-challenge key rotation Then the response status is 422 - Scenario: Confirm fails when the 15-minute challenge window has expired + Scenario: DNS challenge window still active 1 nanosecond before the 15-minute expiry Given an organisation is registered with target level "UNVERIFIED" for domain "example.com" And the agent has initiated DNS-challenge key rotation And the agent has published the rotation challenge to dns - When time advances by 16 minutes + When time advances by 1 nanosecond short of 15 minutes + And the agent confirms DNS-challenge key rotation + Then the response status is 200 + And a new api key with prefix "apix_org_" is returned + And a new rotation secret with prefix "apix_rot_" is returned + + Scenario: Confirm fails at exactly the 15-minute challenge window expiry + Given an organisation is registered with target level "UNVERIFIED" for domain "example.com" + And the agent has initiated DNS-challenge key rotation + And the agent has published the rotation challenge to dns + When time advances by 15 minutes And the agent confirms DNS-challenge key rotation Then the response status is 422 diff --git a/apix-registry/src/integration-test/resources/features/sandbox/sandbox-feedback.feature b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-feedback.feature new file mode 100644 index 0000000..aed2351 --- /dev/null +++ b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-feedback.feature @@ -0,0 +1,58 @@ +Feature: Agent experience feedback + + Background: + Given a sandbox named "feedback-demo" exists + + Scenario: Feedback schema is globally discoverable without authentication + When GET /sandbox/feedback-schema is called without authentication + Then the response code is 200 + And the schema contains at least 9 dimensions + And the schema contains dimension key "hateoas_navigation" + And the schema contains dimension key "liveness_signal_accuracy" + And the schema contains dimension key "error_message_quality" + And the schema contains dimension key "extension_property_coverage" + And the schema scale minimum is 0 and maximum is 10 + + Scenario: Submit valid feedback returns 202 + When feedback is submitted to "feedback-demo" with scores hateoas_navigation=8 discovery_accuracy=7 + Then the response code is 202 + And the response message is "Feedback recorded. Thank you." + And the response contains _links.schema + And the response contains _links.sandbox + + Scenario: Submit feedback with model identity + When feedback is submitted to "feedback-demo" with scores hateoas_navigation=9 and model "claude-sonnet-4-6" provider "anthropic" + Then the response code is 202 + + Scenario: Submit feedback with unknown dimension keys is accepted but ignored + When feedback is submitted to "feedback-demo" with scores unknown_key=5 + Then the response code is 422 + + Scenario: Submit feedback with score out of range returns 422 + When feedback is submitted to "feedback-demo" with scores hateoas_navigation=11 + Then the response code is 422 + + Scenario: Submit feedback with empty scores returns 400 + When feedback is submitted to "feedback-demo" with empty scores + Then the response code is 400 + + Scenario: Aggregate feedback requires sandbox API key + When the feedback aggregate for "feedback-demo" is requested without an API key + Then the response code is 401 + + Scenario: Aggregate feedback shows averages per dimension + Given feedback has been submitted to "feedback-demo" with scores hateoas_navigation=6 discovery_accuracy=8 + And feedback has been submitted to "feedback-demo" with scores hateoas_navigation=10 discovery_accuracy=4 + When the feedback aggregate for "feedback-demo" is requested with the sandbox API key + Then the response code is 200 + And the total submissions is 2 + And the dimension "hateoas_navigation" has average 8.0 + And the dimension "discovery_accuracy" has average 6.0 + + Scenario: Aggregate includes provider breakdown + Given feedback has been submitted to "feedback-demo" with scores hateoas_navigation=7 and model "claude-sonnet-4-6" provider "anthropic" + And feedback has been submitted to "feedback-demo" with scores hateoas_navigation=5 and model "gpt-4o" provider "openai" + When the feedback aggregate for "feedback-demo" is requested with the sandbox API key + Then the response code is 200 + And the submissionsByProvider contains "anthropic" with count 1 + And the submissionsByProvider contains "openai" with count 1 diff --git a/apix-registry/src/integration-test/resources/features/sandbox/sandbox-navigation.feature b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-navigation.feature new file mode 100644 index 0000000..1a0b2fb --- /dev/null +++ b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-navigation.feature @@ -0,0 +1,33 @@ +Feature: HATEOAS navigation and root key resolution + + Background: + Given a sandbox named "nav-demo" exists + + Scenario: Root resource without API key omits _links.sandbox + When the root resource is requested without an API key + Then the response code is 200 + And the response contains _links.registerSandbox + And the response contains _links.feedbackSchema + And the response does not contain _links.sandbox + + Scenario: Root resource with valid sandbox API key includes _links.sandbox + When the root resource is requested with the sandbox API key for "nav-demo" + Then the response code is 200 + And the response contains _links.sandbox ending with "/sandbox/nav-demo" + + Scenario: Root resource with unknown API key omits _links.sandbox + When the root resource is requested with API key "apix_sb_unknownkey" + Then the response code is 200 + And the response does not contain _links.sandbox + + Scenario: Sandbox root endpoint returns sandbox metadata + When the sandbox root for "nav-demo" is requested + Then the response code is 200 + And the response contains sandbox name "nav-demo" + And the response contains _links.services + And the response contains _links.submitFeedback + And the response contains _links.feedbackSchema + + Scenario: Sandbox root for unknown name returns 404 + When the sandbox root for "does-not-exist" is requested + Then the response code is 404 diff --git a/apix-registry/src/integration-test/resources/features/sandbox/sandbox-registration.feature b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-registration.feature new file mode 100644 index 0000000..40c66d5 --- /dev/null +++ b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-registration.feature @@ -0,0 +1,32 @@ +Feature: Sandbox registration + + Scenario: Create a sandbox and receive an API key exactly once + When an agent registers a sandbox named "peter-demo" with email "peter@openclaw.io" + Then the response code is 201 + And the response contains a sandbox id + And the response contains an API key with prefix "apix_sb_" + And the response contains tier "FREE" + And the response contains a non-null expiresAt + And the response contains _links.self ending with "/sandbox/peter-demo" + And the response contains _links.services + + Scenario: Registration fails when name contains uppercase letters + When an agent registers a sandbox named "Peter-Demo" with email "peter@openclaw.io" + Then the response code is 400 + + Scenario: Registration fails when name is too short + When an agent registers a sandbox named "ab" with email "peter@openclaw.io" + Then the response code is 400 + + Scenario: Registration fails when name starts with a hyphen + When an agent registers a sandbox named "-demo" with email "peter@openclaw.io" + Then the response code is 400 + + Scenario: Registration fails when email is invalid + When an agent registers a sandbox named "valid-name" with email "not-an-email" + Then the response code is 400 + + Scenario: Duplicate name is rejected with 409 + Given a sandbox named "duplicate-test" exists + When an agent registers a sandbox named "duplicate-test" with email "other@example.com" + Then the response code is 409 diff --git a/apix-registry/src/integration-test/resources/features/sandbox/sandbox-service-isolation.feature b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-service-isolation.feature new file mode 100644 index 0000000..06df720 --- /dev/null +++ b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-service-isolation.feature @@ -0,0 +1,45 @@ +Feature: Sandbox service isolation from production + + Background: + Given a sandbox named "isolation-test" exists + And a production service "ProdService" with endpoint "https://prod.example.com" is registered + + Scenario: Service registered in sandbox does not appear in production list + Given a sandbox service with endpoint "https://sandbox.example.com" and capability "test.cap" is registered in "isolation-test" + When GET /services is called without authentication + Then the response code is 200 + And "https://sandbox.example.com" is not in the endpoint list + + Scenario: Production service does not appear in sandbox service list + Given a sandbox service with endpoint "https://sandbox.example.com" and capability "test.cap" is registered in "isolation-test" + When the sandbox service list for "isolation-test" is requested + Then the response code is 200 + And "https://prod.example.com" is not in the endpoint list + + Scenario: Service registration in sandbox requires the sandbox API key + When a service is registered in sandbox "isolation-test" without an API key + Then the response code is 401 + + Scenario: Service registration in sandbox with wrong key returns 401 + When a service is registered in sandbox "isolation-test" with API key "apix_sb_wrongkey" + Then the response code is 401 + + Scenario: Sandbox search is isolated from production results + Given a sandbox service with endpoint "https://sb-search.example.com" and capability "search.cap" is registered in "isolation-test" + When sandbox "isolation-test" services are searched by capability "search.cap" + Then the response code is 200 + And "https://sb-search.example.com" is in the endpoint list + + Scenario: Sandbox search does not return production services + Given a production service "ProdSearchService" with endpoint "https://prod-search.example.com" is registered + When sandbox "isolation-test" services are searched by capability "device.telemetry" + Then the response code is 200 + And "https://prod-search.example.com" is not in the endpoint list + + Scenario: Sandbox services can be registered with extension properties and queried by them + Given a sandbox service with endpoint "https://ext-eu.example.com" capability "data.processing" and extension "region:eu" is registered in "isolation-test" + And a sandbox service with endpoint "https://ext-us.example.com" capability "data.processing" and extension "region:us" is registered in "isolation-test" + When sandbox "isolation-test" services are searched by capability "data.processing" and property "region:eu" + Then the response code is 200 + And "https://ext-eu.example.com" is in the endpoint list + And "https://ext-us.example.com" is not in the endpoint list diff --git a/apix-registry/src/integration-test/resources/features/sandbox/sandbox-telemetry.feature b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-telemetry.feature new file mode 100644 index 0000000..9510a93 --- /dev/null +++ b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-telemetry.feature @@ -0,0 +1,43 @@ +Feature: Sandbox telemetry + + Background: + Given a sandbox named "telemetry-demo" exists + + Scenario: Telemetry starts empty before any activity + When the telemetry for "telemetry-demo" is requested with the sandbox API key + Then the response code is 200 + And the usage map is empty or contains only zero counts + + Scenario: Viewing the sandbox root increments the SANDBOX_VIEWED counter + Given the sandbox root for "telemetry-demo" has been viewed once + When the telemetry for "telemetry-demo" is requested with the sandbox API key + Then the response code is 200 + And the usage counter "SANDBOX_VIEWED" is at least 1 + + Scenario: Registering a service increments the SERVICE_REGISTERED counter + Given a sandbox service with endpoint "https://tel.example.com" and capability "tel.cap" is registered in "telemetry-demo" + When the telemetry for "telemetry-demo" is requested with the sandbox API key + Then the response code is 200 + And the usage counter "SERVICE_REGISTERED" is at least 1 + + Scenario: Listing services increments the SERVICE_LISTED counter + Given the sandbox service list for "telemetry-demo" has been requested once + When the telemetry for "telemetry-demo" is requested with the sandbox API key + Then the response code is 200 + And the usage counter "SERVICE_LISTED" is at least 1 + + Scenario: Telemetry requires the sandbox API key + When the telemetry for "telemetry-demo" is requested without an API key + Then the response code is 401 + + Scenario: Telemetry with wrong key returns 401 + When the telemetry for "telemetry-demo" is requested with API key "apix_sb_wrongkey" + Then the response code is 401 + + Scenario: Telemetry response includes tier metadata + When the telemetry for "telemetry-demo" is requested with the sandbox API key + Then the response code is 200 + And the telemetry contains tier "FREE" + And the telemetry contains ratePerMinute 60 + And the telemetry contains maxServices 10 + And the telemetry contains maxOrgs 3 diff --git a/apix-registry/src/integration-test/resources/features/sandbox/sandbox-tier-caps.feature b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-tier-caps.feature new file mode 100644 index 0000000..388bc77 --- /dev/null +++ b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-tier-caps.feature @@ -0,0 +1,8 @@ +Feature: Sandbox tier caps + + Scenario: FREE sandbox allows up to 10 services + Given a sandbox named "cap-test" exists + And 10 services have been registered in sandbox "cap-test" + When a service is registered in sandbox "cap-test" with the sandbox API key + Then the response code is 429 + And the sandbox error contains "Service limit reached" diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/RegistryApiConfig.java b/apix-registry/src/main/java/org/botstandards/apix/registry/RegistryApiConfig.java new file mode 100644 index 0000000..e663892 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/RegistryApiConfig.java @@ -0,0 +1,52 @@ +package org.botstandards.apix.registry; + +import org.eclipse.microprofile.openapi.annotations.OpenAPIDefinition; +import org.eclipse.microprofile.openapi.annotations.info.Contact; +import org.eclipse.microprofile.openapi.annotations.info.Info; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; +import org.eclipse.microprofile.openapi.annotations.security.SecurityScheme; +import org.eclipse.microprofile.openapi.annotations.security.SecuritySchemes; +import org.eclipse.microprofile.openapi.annotations.enums.SecuritySchemeIn; +import org.eclipse.microprofile.openapi.annotations.enums.SecuritySchemeType; +import jakarta.ws.rs.core.Application; + +@OpenAPIDefinition( + info = @Info( + title = "APIX Registry API", + version = "0.1", + description = """ + The open autonomous agent service discovery registry. + + ## Agent Workflow + + 1. `GET /` — Read the HATEOAS root to discover all entry points and this OpenAPI spec URL. + 2. `GET /services?capability=` — Search for PRODUCTION services by capability keyword \ + (e.g. nlp, translation, speech-to-text, image-classification). + 3. Follow `openApiSpecUrl` or `mcpSpecUrl` in the returned service record to learn how to call it. + + ## Registering a Service + + POST /services with a BSM payload and an `X-Api-Key` header. \ + The endpoint URL is the unique key — re-posting the same endpoint updates the existing record (UPSERT). + + ## Verification Levels (O-levels) + + Services start at UNVERIFIED. The registry progressively verifies registrant identity (O1 DNS), \ + legal entity (O2 GLEIF/LEI), and technical hygiene (O3). Higher O-levels indicate greater \ + trustworthiness. Filter replacement candidates by minimum O-level via \ + `GET /services/{id}/replacements?minOLevel=LEGAL_ENTITY_VERIFIED`. + """, + contact = @Contact(url = "https://api-index.org") + ), + security = @SecurityRequirement(name = "ApiKey") +) +@SecuritySchemes( + @SecurityScheme( + securitySchemeName = "ApiKey", + type = SecuritySchemeType.APIKEY, + apiKeyName = "X-Api-Key", + in = SecuritySchemeIn.HEADER, + description = "Registry write key. Required for POST /services, PATCH /services/{id}, and all other write operations. Read operations (GET) are unauthenticated. Contact the registry operator at https://api-index.org to obtain a key." + ) +) +public class RegistryApiConfig extends Application {} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/DeviceIndexResponse.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/DeviceIndexResponse.java new file mode 100644 index 0000000..dd438c8 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/DeviceIndexResponse.java @@ -0,0 +1,17 @@ +package org.botstandards.apix.registry.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record DeviceIndexResponse( + @JsonProperty("_links") DeviceLinks links +) { + public record DeviceLinks( + LinkRef self, + LinkRef search, + LinkRef replacement + ) { + public record LinkRef(String href, boolean templated) { + public LinkRef(String href) { this(href, false); } + } + } +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/FeedbackAggregateResponse.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/FeedbackAggregateResponse.java new file mode 100644 index 0000000..ee80b62 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/FeedbackAggregateResponse.java @@ -0,0 +1,21 @@ +package org.botstandards.apix.registry.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; + +public record FeedbackAggregateResponse( + String sandboxId, + String name, + int totalSubmissions, + List scores, + Map submissionsByProvider, + @JsonProperty("_links") SandboxLinks links +) { + public record DimensionScore( + String key, + String question, + double average, + int votes + ) {} +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/FeedbackDimension.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/FeedbackDimension.java new file mode 100644 index 0000000..8a34e12 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/FeedbackDimension.java @@ -0,0 +1,9 @@ +package org.botstandards.apix.registry.dto; + +/** A single rated dimension in the agent experience feedback schema. */ +public record FeedbackDimension( + String key, + String question, + String minLabel, + String maxLabel +) {} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/FeedbackSchemaResponse.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/FeedbackSchemaResponse.java new file mode 100644 index 0000000..06db71d --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/FeedbackSchemaResponse.java @@ -0,0 +1,15 @@ +package org.botstandards.apix.registry.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +public record FeedbackSchemaResponse( + String description, + Scale scale, + List dimensions, + @JsonProperty("_links") FeedbackSchemaLinks links +) { + public record Scale(int min, int max, String minLabel, String maxLabel) {} + + public record FeedbackSchemaLinks(SandboxLinks.LinkRef self) {} +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/FeedbackSubmissionRequest.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/FeedbackSubmissionRequest.java new file mode 100644 index 0000000..f1be583 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/FeedbackSubmissionRequest.java @@ -0,0 +1,25 @@ +package org.botstandards.apix.registry.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import java.util.Map; + +public record FeedbackSubmissionRequest( + + @NotEmpty + Map scores, + + @Size(max = 255) + String agentIdentifier, + + @Size(max = 500) + String comment, + + /** Full model identifier as the agent knows it, e.g. "claude-sonnet-4-6", "gpt-4o-2024-11-20". */ + @Size(max = 255) + String modelIdentifier, + + /** Provider family: "anthropic", "openai", "google", "meta", "mistral" … */ + @Size(max = 100) + String modelProvider +) {} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/IndexResponse.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/IndexResponse.java new file mode 100644 index 0000000..8f6b312 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/IndexResponse.java @@ -0,0 +1,44 @@ +package org.botstandards.apix.registry.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +public record IndexResponse( + String apixVersion, + String name, + String description, + RegistryStats stats, + @JsonProperty("_links") RegistryLinks links +) { + public record RegistryStats( + long registeredServices, + long liveServices + ) {} + + public record RegistryLinks( + LinkRef self, + LinkRef services, + LinkRef servicesSearch, + LinkRef devices, + LinkRef organizations, + LinkRef health, + LinkRef openapi, + LinkRef registerSandbox, + LinkRef feedbackSchema, + @JsonInclude(JsonInclude.Include.NON_NULL) + LinkRef sandbox + ) { + /** Constructor for anonymous / non-sandbox-keyed requests. */ + public RegistryLinks(LinkRef self, LinkRef services, LinkRef servicesSearch, + LinkRef devices, LinkRef organizations, + LinkRef health, LinkRef openapi, LinkRef registerSandbox, + LinkRef feedbackSchema) { + this(self, services, servicesSearch, devices, organizations, + health, openapi, registerSandbox, feedbackSchema, null); + } + + public record LinkRef(String href, boolean templated) { + public LinkRef(String href) { this(href, false); } + } + } +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgRegistrationRequest.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgRegistrationRequest.java index 6f77b31..dee0aae 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgRegistrationRequest.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgRegistrationRequest.java @@ -5,13 +5,28 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import org.botstandards.apix.common.OLevel; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +@Schema(description = "Request body for registering a new organisation. An organisation record establishes the legal identity of a registrant and is the starting point for O-level verification. Services are registered independently via POST /services using the registrant fields in the BSM payload — no prior organisation registration is required to submit a service.") @JsonIgnoreProperties(ignoreUnknown = true) public record OrgRegistrationRequest( + + @Schema(description = "Full legal name of the organisation or individual.", example = "Acme GmbH") @NotBlank String registrantName, + + @Schema(description = "Contact email. Used for O-level verification notifications and key rotation challenges.", example = "ops@acme.example") @NotBlank @Email String registrantEmail, + + @Schema(description = "ISO 3166-1 alpha-2 country code of the legal jurisdiction.", example = "DE") @NotBlank String registrantJurisdiction, + + @Schema(description = "Legal form: INDIVIDUAL, COMMERCIAL, NON_PROFIT, GOVERNMENT, ACADEMIC.") @NotBlank String registrantOrgType, + + @Schema(description = "Primary internet domain controlled by this organisation (e.g. acme.example). The registry places a DNS TXT record challenge on this domain for O1 identity verification.", example = "acme.example") @NotBlank String domain, + + @Schema(description = "Desired verification level. The registry initiates verification automatically up to this level. Use IDENTITY_VERIFIED (O1) to start; upgrade later via PATCH /organizations/{id}/request-upgrade.") @NotNull OLevel targetOLevel + ) {} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/SandboxIndexResponse.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/SandboxIndexResponse.java new file mode 100644 index 0000000..e56b82d --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/SandboxIndexResponse.java @@ -0,0 +1,13 @@ +package org.botstandards.apix.registry.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.Instant; + +public record SandboxIndexResponse( + String sandboxId, + String name, + String tier, + int ratePerMinute, + Instant expiresAt, + @JsonProperty("_links") SandboxLinks links +) {} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/SandboxLinks.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/SandboxLinks.java new file mode 100644 index 0000000..fbd09ea --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/SandboxLinks.java @@ -0,0 +1,20 @@ +package org.botstandards.apix.registry.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Shared HATEOAS link structure for sandbox responses. */ +public record SandboxLinks( + LinkRef self, + LinkRef services, + @JsonProperty("servicesSearch") LinkRef servicesSearch, + LinkRef submitFeedback, + LinkRef feedbackSchema, + /** Dashboard URL on the portal. Null if portal URL is not configured. */ + @JsonInclude(JsonInclude.Include.NON_NULL) + LinkRef dashboard +) { + public record LinkRef(String href, boolean templated) { + public LinkRef(String href) { this(href, false); } + } +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/SandboxRegistrationRequest.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/SandboxRegistrationRequest.java new file mode 100644 index 0000000..7e50cdd --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/SandboxRegistrationRequest.java @@ -0,0 +1,27 @@ +package org.botstandards.apix.registry.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record SandboxRegistrationRequest( + + @NotBlank + @Size(min = 3, max = 100) + @Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", + message = "name must be lowercase alphanumeric with hyphens, no leading or trailing hyphen") + String name, + + @Email + @Size(max = 255) + String contactEmail, + + /** + * Optional: owner-declared location shown on the sandbox map (e.g. "Berlin, Germany"). + * Raw registration IPs are never stored. If provided, geocoded once at registration time. + * Omit or set null to register anonymously — sandbox functions identically either way. + */ + @Size(max = 200) + String location +) {} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/SandboxRegistrationResponse.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/SandboxRegistrationResponse.java new file mode 100644 index 0000000..c8e79aa --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/SandboxRegistrationResponse.java @@ -0,0 +1,21 @@ +package org.botstandards.apix.registry.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.Instant; + +public record SandboxRegistrationResponse( + String sandboxId, + String name, + /** Plaintext API key — shown exactly once. Embed in agents as X-Api-Key. */ + String apiKey, + /** Plaintext maintenance key — shown exactly once. Keep private; used only for lifecycle operations. */ + String maintenanceKey, + String tier, + int ratePerMinute, + Instant expiresAt, + /** Dashboard URL on the portal — bookmark this to monitor your sandbox. */ + @JsonInclude(JsonInclude.Include.NON_NULL) + String dashboardUrl, + @JsonProperty("_links") SandboxLinks links +) {} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/SandboxTelemetryResponse.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/SandboxTelemetryResponse.java new file mode 100644 index 0000000..ef24b71 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/SandboxTelemetryResponse.java @@ -0,0 +1,24 @@ +package org.botstandards.apix.registry.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.Instant; +import java.util.Map; + +public record SandboxTelemetryResponse( + String sandboxId, + String name, + String tier, + int ratePerMinute, + Instant expiresAt, + /** NULL = unlimited (COMMUNITY / FOUNDER). */ + @JsonInclude(JsonInclude.Include.ALWAYS) + Integer maxServices, + @JsonInclude(JsonInclude.Include.ALWAYS) + Integer maxOrgs, + /** Cumulative counts keyed by event type since sandbox creation. */ + Map usage, + /** Timestamp of the most recent tracked request across all event types. */ + Instant lastActivityAt, + @JsonProperty("_links") SandboxLinks links +) {} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/ServiceResponse.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/ServiceResponse.java index a19ab14..8344e43 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/ServiceResponse.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/ServiceResponse.java @@ -7,6 +7,7 @@ import org.botstandards.apix.registry.dto.IotProfileResponse; import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.UUID; @JsonInclude(JsonInclude.Include.NON_NULL) @@ -37,7 +38,9 @@ public record ServiceResponse( List replacesServiceIds, Instant registeredAt, Instant lastUpdatedAt, - IotProfileResponse iotProfile + IotProfileResponse iotProfile, + @JsonInclude(JsonInclude.Include.NON_EMPTY) + Map extensions ) { public static ServiceResponse from(ServiceEntity e) { BsmPayload b = e.bsmPayload; @@ -68,7 +71,8 @@ public record ServiceResponse( b.replacesServiceIds(), e.registeredAt, e.lastUpdatedAt, - IotProfileResponse.from(e.iotProfile) + IotProfileResponse.from(e.iotProfile), + b.extensions() ); } } diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/entity/SandboxEntity.java b/apix-registry/src/main/java/org/botstandards/apix/registry/entity/SandboxEntity.java new file mode 100644 index 0000000..4473be2 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/entity/SandboxEntity.java @@ -0,0 +1,59 @@ +package org.botstandards.apix.registry.entity; + +import jakarta.persistence.*; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "sandboxes") +public class SandboxEntity { + + @Id + @Column(columnDefinition = "uuid") + public UUID id; + + @Column(nullable = false) + public String name; + + @Column(name = "contact_email") + public String contactEmail; + + @Column(name = "api_key_hash", nullable = false, unique = true) + public String apiKeyHash; + + @Column(nullable = false) + public String tier; + + @Column(name = "rate_per_minute", nullable = false) + public int ratePerMinute; + + /** NULL = unlimited (COMMUNITY / FOUNDER tiers, or BSF manual override). */ + @Column(name = "max_services") + public Integer maxServices; + + /** NULL = unlimited. */ + @Column(name = "max_orgs") + public Integer maxOrgs; + + @Column(name = "created_at", nullable = false) + public Instant createdAt; + + @Column(name = "expires_at", nullable = false) + public Instant expiresAt; + + /** Owner-declared location string. Null if not provided at registration. */ + @Column(name = "registrar_location") + public String registrarLocation; + + /** Geocoded latitude of registrarLocation. Null if not provided or geocoding failed. */ + @Column(name = "registrar_lat") + public Double registrarLat; + + /** Geocoded longitude of registrarLocation. Null if not provided or geocoding failed. */ + @Column(name = "registrar_lon") + public Double registrarLon; + + /** SHA-256 hash of the maintenance key. Used for lifecycle operations (extend, rotate). */ + @Column(name = "maintenance_key_hash", nullable = false) + public String maintenanceKeyHash; +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/entity/ServiceEntity.java b/apix-registry/src/main/java/org/botstandards/apix/registry/entity/ServiceEntity.java index e680461..61ce424 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/entity/ServiceEntity.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/entity/ServiceEntity.java @@ -18,9 +18,12 @@ public class ServiceEntity { @Column(columnDefinition = "uuid") public UUID id; - @Column(name = "endpoint_url", nullable = false, unique = true) + @Column(name = "endpoint_url", nullable = false) public String endpointUrl; + @Column(name = "sandbox_id") + public String sandboxId; + @Convert(converter = BsmPayloadConverter.class) @Column(name = "bsm_payload", columnDefinition = "jsonb", nullable = false) @ColumnTransformer(write = "?::jsonb") diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/filter/CacheControlFilter.java b/apix-registry/src/main/java/org/botstandards/apix/registry/filter/CacheControlFilter.java new file mode 100644 index 0000000..91162dd --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/filter/CacheControlFilter.java @@ -0,0 +1,63 @@ +package org.botstandards.apix.registry.filter; + +import jakarta.annotation.Priority; +import jakarta.ws.rs.Priorities; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerResponseContext; +import jakarta.ws.rs.container.ContainerResponseFilter; +import jakarta.ws.rs.ext.Provider; + +/** + * Sets Cache-Control on every response so Bunny.net edge nodes know what to + * cache and for how long. Rules: + * + * GET / → public, max-age=60 (root nav links are stable) + * GET /services, /devices → public, max-age=30 (capability search results) + * GET /organizations → public, max-age=30 + * GET /q/* → no-store (health + metrics — never cache) + * 4xx / 5xx responses → no-store (errors must not be served from edge) + * Non-GET methods → no-store (writes must always reach origin) + * + * Bunny.net reads the Cache-Control max-age as the edge TTL when the pull zone + * is configured with "Use Cache-Control headers" enabled (set in setup-bunnynet.sh). + * Query-string variation is handled by the CDN pull zone config — Bunny.net caches + * /services?capability=nlp and /services?capability=translation as separate entries. + */ +@Provider +@Priority(Priorities.HEADER_DECORATOR) +public class CacheControlFilter implements ContainerResponseFilter { + + @Override + public void filter(ContainerRequestContext req, ContainerResponseContext res) { + if (!"GET".equals(req.getMethod())) { + set(res, "no-store"); + return; + } + + String path = req.getUriInfo().getPath(); + + if (isInternalPath(path)) { + set(res, "no-store"); + return; + } + + if (res.getStatus() >= 400) { + set(res, "no-store"); + return; + } + + if (path.equals("/") || path.isEmpty()) { + set(res, "public, max-age=60"); + } else { + set(res, "public, max-age=30"); + } + } + + private boolean isInternalPath(String path) { + return path.startsWith("q/") || path.startsWith("/q/"); + } + + private void set(ContainerResponseContext res, String value) { + res.getHeaders().putSingle("Cache-Control", value); + } +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/filter/CanonicalQueryFilter.java b/apix-registry/src/main/java/org/botstandards/apix/registry/filter/CanonicalQueryFilter.java new file mode 100644 index 0000000..f3c2aae --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/filter/CanonicalQueryFilter.java @@ -0,0 +1,82 @@ +package org.botstandards.apix.registry.filter; + +import jakarta.annotation.Priority; +import jakarta.inject.Inject; +import jakarta.ws.rs.Priorities; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; +import jakarta.ws.rs.ext.Provider; +import org.botstandards.apix.registry.normalisation.QueryNormalisationService; + +import java.io.IOException; +import java.net.URI; + +/** + * Redirects non-canonical query strings to their canonical form so that + * Bunny.net stores all semantically equivalent requests under one cache key. + * + * Three-step flow: + * 1. Normalize parameter order and values (QueryNormalisationService) + * 2. Compare canonical form to incoming URL + * 3. If different → 302 to canonical URL; CDN then caches the final response + * under the canonical key. If already canonical → pass through unchanged. + * + * Why values must stay in the cache key: + * ?capability=nlp and ?capability=translation are different result sets. + * Removing values would collapse them into one entry — wrong data served. + * The canonical form normalises the representation (case, order, defaults) + * without losing the semantics encoded in the values. + * + * Why 302 (not 301): + * 301 would itself be cached at the CDN edge. With 302, only the destination + * response (the canonical-URL 200) enters the CDN cache. Clients still + * learn the canonical form and subsequent requests skip the redirect. + * + * Paths covered: GET /services, GET /devices, GET /…/replacements. + * All other paths — including /q/* health and non-GET methods — pass through. + */ +@Provider +@Priority(Priorities.USER) +public class CanonicalQueryFilter implements ContainerRequestFilter { + + @Inject + QueryNormalisationService normalisationService; + + @Override + public void filter(ContainerRequestContext ctx) throws IOException { + if (!"GET".equals(ctx.getMethod())) return; + + String path = ctx.getUriInfo().getPath(); + String canonical = resolveCanonical(path, ctx); + if (canonical == null) return; // path not subject to canonicalisation + + String incoming = rawQuery(ctx); + if (canonical.equals(incoming)) return; // already canonical — pass through + + ctx.abortWith(Response.status(Response.Status.FOUND) + .location(canonicalUri(ctx, canonical)) + .build()); + } + + private String resolveCanonical(String path, ContainerRequestContext ctx) { + var q = ctx.getUriInfo().getQueryParameters(); + String p = path.startsWith("/") ? path.substring(1) : path; + if (p.equals("services")) return normalisationService.canonicalForServices(q); + if (p.equals("devices")) return normalisationService.canonicalForDevices(q); + if (p.endsWith("/replacements")) return normalisationService.canonicalForReplacements(q); + return null; + } + + private String rawQuery(ContainerRequestContext ctx) { + String raw = ctx.getUriInfo().getRequestUri().getRawQuery(); + return raw != null ? raw : ""; + } + + private URI canonicalUri(ContainerRequestContext ctx, String canonicalQuery) { + UriBuilder b = UriBuilder.fromUri(ctx.getUriInfo().getAbsolutePath()); + if (!canonicalQuery.isEmpty()) b.replaceQuery(canonicalQuery); + return b.build(); + } +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/normalisation/QueryNormalisationService.java b/apix-registry/src/main/java/org/botstandards/apix/registry/normalisation/QueryNormalisationService.java new file mode 100644 index 0000000..4fe6886 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/normalisation/QueryNormalisationService.java @@ -0,0 +1,104 @@ +package org.botstandards.apix.registry.normalisation; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.core.MultivaluedMap; + +import java.util.Map; +import java.util.TreeMap; + +import static java.util.stream.Collectors.joining; + +/** + * Produces a canonical query string for agent-facing search endpoints. + * + * Rules applied per parameter: + * capability → lowercase, trimmed; omitted if blank + * stage → uppercase; omitted if equals the default "PRODUCTION" + * deviceClass → lowercase, trimmed; omitted if blank + * protocol → uppercase, trimmed; omitted if blank + * minOLevel → uppercase, trimmed; omitted if blank + * + * Unknown parameters are silently dropped — forward-compatibility. + * Remaining parameters are sorted alphabetically to produce a stable cache key. + * + * The canonical form is what Bunny.net uses as the CDN cache key and what + * Micrometer counters use as metric tag values. Any variant that normalises + * to the same canonical form hits the same cache entry and increments the + * same counter. + */ +@ApplicationScoped +public class QueryNormalisationService { + + private static final String DEFAULT_STAGE = "PRODUCTION"; + + /** + * Returns the canonical query string for a /services search. + * Empty string means no meaningful parameters — bare /services URL. + */ + public String canonicalForServices(MultivaluedMap raw) { + Map out = new TreeMap<>(); + put(out, "capability", normaliseCapability(raw.getFirst("capability"))); + putIfNotDefault(out, "stage", normaliseEnum(raw.getFirst("stage")), DEFAULT_STAGE); + return render(out); + } + + /** + * Returns the canonical query string for a /devices search. + * Empty string means no meaningful parameters — bare /devices URL. + */ + public String canonicalForDevices(MultivaluedMap raw) { + Map out = new TreeMap<>(); + put(out, "capability", normaliseCapability(raw.getFirst("capability"))); + put(out, "deviceClass", normaliseLower(raw.getFirst("deviceClass"))); + put(out, "protocol", normaliseEnum(raw.getFirst("protocol"))); + return render(out); + } + + /** + * Returns the canonical query string for a /{id}/replacements search. + * Empty string means no filter parameters. + */ + public String canonicalForReplacements(MultivaluedMap raw) { + Map out = new TreeMap<>(); + put(out, "deviceClass", normaliseLower(raw.getFirst("deviceClass"))); + put(out, "minOLevel", normaliseEnum(raw.getFirst("minOLevel"))); + put(out, "protocol", normaliseEnum(raw.getFirst("protocol"))); + return render(out); + } + + // ── Individual value normalisers — package-private for unit tests ────────── + + String normaliseCapability(String v) { + if (v == null) return null; + String s = v.strip().toLowerCase(); + return s.isEmpty() ? null : s; + } + + String normaliseLower(String v) { + if (v == null) return null; + String s = v.strip().toLowerCase(); + return s.isEmpty() ? null : s; + } + + String normaliseEnum(String v) { + if (v == null) return null; + String s = v.strip().toUpperCase(); + return s.isEmpty() ? null : s; + } + + // ── Private helpers ──────────────────────────────────────────────────────── + + private void put(Map map, String key, String value) { + if (value != null) map.put(key, value); + } + + private void putIfNotDefault(Map map, String key, String value, String defaultValue) { + if (value != null && !value.equals(defaultValue)) map.put(key, value); + } + + private String render(Map params) { + return params.entrySet().stream() + .map(e -> e.getKey() + "=" + e.getValue()) + .collect(joining("&")); + } +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/resource/DeviceResource.java b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/DeviceResource.java new file mode 100644 index 0000000..2ab7173 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/DeviceResource.java @@ -0,0 +1,95 @@ +package org.botstandards.apix.registry.resource; + +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.botstandards.apix.registry.dto.DeviceIndexResponse; +import org.botstandards.apix.registry.dto.DeviceIndexResponse.DeviceLinks; +import org.botstandards.apix.registry.dto.DeviceIndexResponse.DeviceLinks.LinkRef; +import org.botstandards.apix.registry.dto.ReplacementsResponse; +import org.botstandards.apix.registry.dto.ServiceResponse; +import org.botstandards.apix.registry.service.RegistryService; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.util.List; +import java.util.UUID; + +@Path("/devices") +@Produces(MediaType.APPLICATION_JSON) +public class DeviceResource { + + @Inject + RegistryService registryService; + + @Inject + MeterRegistry meters; + + @ConfigProperty(name = "apix.registry.base-url") + String baseUrl; + + /** + * No query params → navigation document. + * Any query param → device service search. + * + * An agent navigates here first, reads _links.search, fills the template, + * then calls the resulting URL — which routes back to this same method. + */ + @GET + public Response index( + @QueryParam("capability") String capability, + @QueryParam("deviceClass") String deviceClass, + @QueryParam("protocol") String protocol) { + + if (capability == null && deviceClass == null && protocol == null) { + return Response.ok(buildIndex()).build(); + } + var results = registryService + .searchDevices(capability, deviceClass, protocol) + .stream().map(ServiceResponse::from).toList(); + + meters.counter("apix.search.devices", + "capability", tv(capability), + "deviceClass", tv(deviceClass), + "protocol", tv(protocol)) + .increment(); + meters.summary("apix.search.result_count", + "resource", "devices", + "capability", tv(capability)) + .record(results.size()); + + return Response.ok(results).build(); + } + + @GET + @Path("/{id}") + public ServiceResponse getById(@PathParam("id") UUID id) { + return ServiceResponse.from(registryService.requireDeviceById(id)); + } + + @GET + @Path("/{id}/replacements") + public ReplacementsResponse getReplacements( + @PathParam("id") UUID id, + @QueryParam("minOLevel") String minOLevel, + @QueryParam("deviceClass") String deviceClass, + @QueryParam("protocol") String protocol) { + // iotReady=true is implicit: the /devices path only surfaces IoT-compatible candidates + return registryService.getReplacements(id, minOLevel, true, deviceClass, protocol); + } + + private static String tv(String v) { + if (v == null || v.isBlank()) return "_none"; + return v.length() > 64 ? v.substring(0, 64) : v; + } + + private DeviceIndexResponse buildIndex() { + var links = new DeviceLinks( + new LinkRef(baseUrl + "/devices"), + new LinkRef(baseUrl + "/devices{?capability,deviceClass,protocol}", true), + new LinkRef(baseUrl + "/devices/{id}/replacements{?deviceClass,protocol,minOLevel}", true) + ); + return new DeviceIndexResponse(links); + } +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/resource/IndexResource.java b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/IndexResource.java new file mode 100644 index 0000000..899fced --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/IndexResource.java @@ -0,0 +1,68 @@ +package org.botstandards.apix.registry.resource; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.botstandards.apix.registry.dto.IndexResponse; +import org.botstandards.apix.registry.dto.IndexResponse.RegistryLinks; +import org.botstandards.apix.registry.dto.IndexResponse.RegistryLinks.LinkRef; +import org.botstandards.apix.registry.dto.IndexResponse.RegistryStats; +import org.botstandards.apix.registry.entity.SandboxEntity; +import org.botstandards.apix.registry.service.RegistryService; +import org.botstandards.apix.registry.service.SandboxService; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +@Path("/") +@Produces(MediaType.APPLICATION_JSON) +public class IndexResource { + + @Inject + RegistryService registryService; + + @Inject + SandboxService sandboxService; + + @ConfigProperty(name = "apix.registry.base-url") + String baseUrl; + + @ConfigProperty(name = "apix.registry.name", defaultValue = "APIX Registry") + String registryName; + + @ConfigProperty(name = "apix.registry.description", + defaultValue = "The open autonomous agent service discovery registry. " + + "Follow _links.services to browse, or _links.servicesSearch to filter by capability.") + String registryDescription; + + @GET + public IndexResponse index(@HeaderParam("X-Api-Key") String apiKey) { + var stats = new RegistryStats( + registryService.countAll(), + registryService.countLive() + ); + + LinkRef sandboxLink = null; + if (apiKey != null && !apiKey.isBlank()) { + SandboxEntity sandbox = sandboxService.findByKey(apiKey); + if (sandbox != null) { + sandboxLink = new LinkRef(baseUrl + "/sandbox/" + sandbox.name); + } + } + + var links = new RegistryLinks( + new LinkRef(baseUrl + "/"), + new LinkRef(baseUrl + "/services"), + new LinkRef(baseUrl + "/services{?capability,stage,property}", true), + new LinkRef(baseUrl + "/devices"), + new LinkRef(baseUrl + "/organizations"), + new LinkRef(baseUrl + "/q/health"), + new LinkRef(baseUrl + "/q/openapi"), + new LinkRef(baseUrl + "/sandbox/register"), + new LinkRef(baseUrl + "/sandbox/feedback-schema"), + sandboxLink + ); + return new IndexResponse("0.1", registryName, registryDescription, stats, links); + } +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/resource/SandboxResource.java b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/SandboxResource.java new file mode 100644 index 0000000..dae35d3 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/SandboxResource.java @@ -0,0 +1,280 @@ +package org.botstandards.apix.registry.resource; + +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.botstandards.apix.common.BsmPayload; +import org.botstandards.apix.common.SandboxDashboardResponse; +import org.botstandards.apix.registry.dto.*; +import org.botstandards.apix.registry.entity.SandboxEntity; +import org.botstandards.apix.registry.service.SandboxService; +import org.botstandards.apix.registry.service.SandboxService.SandboxCreationResult; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Path("/sandbox") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class SandboxResource { + + @Inject + SandboxService sandboxService; + + @Inject + MeterRegistry meters; + + @ConfigProperty(name = "apix.registry.base-url") + String baseUrl; + + @ConfigProperty(name = "apix.portal.base-url", defaultValue = "") + String portalBaseUrl; + + @ConfigProperty(name = "apix.api-key", defaultValue = "") + String adminApiKey; + + @POST + @Path("/register") + @Operation( + summary = "Create a sandbox namespace", + description = "Creates an isolated test namespace. Returns the sandbox UUID and an API key " + + "shown exactly once — store both immediately. The UUID is the permanent resource identifier " + + "used in all subsequent requests. The name is a human-readable label only; it does not " + + "need to be unique. Free tier: 30 days lifetime, 60 req/min." + ) + public Response register(@Valid SandboxRegistrationRequest req) { + SandboxCreationResult result = sandboxService.create(req.name(), req.contactEmail(), req.location()); + SandboxEntity sb = result.sandbox(); + + String dashboardUrl = portalBaseUrl.isBlank() ? null + : portalBaseUrl + "/sandbox/" + sb.id; + + var body = new SandboxRegistrationResponse( + sb.id.toString(), sb.name, result.plainKey(), result.plainMaintenanceKey(), + sb.tier, sb.ratePerMinute, sb.expiresAt, + dashboardUrl, + sandboxService.sandboxLinks(baseUrl + "/sandbox/" + sb.id, sb.id.toString())); + + return Response.created(URI.create("/sandbox/" + sb.id)).entity(body).build(); + } + + @GET + @Path("/{uuid}") + @Operation( + summary = "Sandbox root — HATEOAS navigation and dashboard data", + description = "Returns sandbox metadata, navigation links, usage stats, and agent visit data. " + + "No authentication required. The UUID is the permanent resource identifier." + ) + public SandboxDashboardResponse index(@PathParam("uuid") String uuidStr) { + UUID id = parseUuid(uuidStr); + SandboxDashboardResponse dashboard = sandboxService.getDashboard(id); + sandboxService.recordUsage(id.toString(), SandboxService.EVENT_SANDBOX_VIEWED); + meters.counter("apix.sandbox.views", "sandbox", id.toString()).increment(); + return dashboard; + } + + @POST + @Path("/{uuid}/services") + @SecurityRequirement(name = "SandboxApiKey") + @Operation( + summary = "Register a service in the sandbox", + description = "Registers a test service. No KYC or O-level enforcement. " + + "Requires X-Api-Key matching this sandbox's key." + ) + public Response registerService(@PathParam("uuid") String uuidStr, + @Valid BsmPayload payload, + @HeaderParam("X-Api-Key") String apiKey, + @HeaderParam("X-Forwarded-For") String forwardedFor) { + UUID id = parseUuid(uuidStr); + SandboxEntity sb = sandboxService.requireAuth(id, apiKey); + var service = sandboxService.registerService(sb.id.toString(), payload); + sandboxService.recordUsage(sb.id.toString(), SandboxService.EVENT_SERVICE_REGISTERED); + sandboxService.recordAgentVisit(sb.id.toString(), forwardedFor); + meters.counter("apix.sandbox.services.registered", "sandbox", sb.id.toString()).increment(); + return Response.created(URI.create("/sandbox/" + sb.id + "/services/" + service.id)) + .entity(Map.of("id", service.id.toString())) + .build(); + } + + @GET + @Path("/{uuid}/services") + @Operation( + summary = "List or search services in the sandbox", + description = "Returns services registered in this sandbox. " + + "Optionally filter by ?capability=, ?stage=, and ?property=key:value. " + + "Multiple ?property= parameters are ANDed together. No authentication required." + ) + public List listServices( + @PathParam("uuid") String uuidStr, + @QueryParam("capability") String capability, + @QueryParam("stage") String stage, + @QueryParam("property") List properties, + @HeaderParam("X-Forwarded-For") String forwardedFor) { + UUID id = parseUuid(uuidStr); + SandboxEntity sb = sandboxService.requireById(id); + if (capability != null && !capability.isBlank()) { + sandboxService.recordUsage(sb.id.toString(), SandboxService.EVENT_SERVICE_SEARCHED); + sandboxService.recordAgentVisit(sb.id.toString(), forwardedFor); + meters.counter("apix.sandbox.services.searched", "sandbox", sb.id.toString()).increment(); + return sandboxService.searchServices(sb.id.toString(), capability, stage, properties); + } + sandboxService.recordUsage(sb.id.toString(), SandboxService.EVENT_SERVICE_LISTED); + meters.counter("apix.sandbox.services.listed", "sandbox", sb.id.toString()).increment(); + return sandboxService.listServices(sb.id.toString()); + } + + @GET + @Path("/{uuid}/telemetry") + @SecurityRequirement(name = "SandboxApiKey") + @Operation( + summary = "Sandbox usage statistics", + description = "Returns cumulative request counts by event type, last activity timestamp, " + + "and tier metadata. Requires X-Api-Key matching this sandbox's key." + ) + public SandboxTelemetryResponse telemetry(@PathParam("uuid") String uuidStr, + @HeaderParam("X-Api-Key") String apiKey) { + UUID id = parseUuid(uuidStr); + SandboxEntity sb = sandboxService.requireAuth(id, apiKey); + return sandboxService.getTelemetry(sb, baseUrl); + } + + @GET + @Path("/feedback-schema") + @Operation( + summary = "Agent experience feedback schema", + description = "Returns the rated dimensions with question text and scale labels. " + + "Agents read this before submitting feedback. No authentication required." + ) + public FeedbackSchemaResponse feedbackSchema() { + return SandboxService.feedbackSchema(baseUrl); + } + + @POST + @Path("/{uuid}/feedback") + @Operation( + summary = "Submit agent experience feedback", + description = "Records dimension scores (0–10) for this sandbox. No authentication required. " + + "Unknown dimension keys are ignored. At least one valid dimension key is required." + ) + public Response submitFeedback(@PathParam("uuid") String uuidStr, + @Valid FeedbackSubmissionRequest req) { + UUID id = parseUuid(uuidStr); + SandboxEntity sb = sandboxService.requireById(id); + sandboxService.submitFeedback(sb.id.toString(), req); + meters.counter("apix.sandbox.feedback.submitted", "sandbox", sb.id.toString()).increment(); + return Response.accepted() + .entity(Map.of( + "message", "Feedback recorded. Thank you.", + "_links", Map.of( + "schema", baseUrl + "/sandbox/feedback-schema", + "sandbox", baseUrl + "/sandbox/" + sb.id))) + .build(); + } + + @GET + @Path("/{uuid}/feedback") + @SecurityRequirement(name = "SandboxApiKey") + @Operation( + summary = "Aggregated feedback results", + description = "Returns average scores per dimension across all submissions for this sandbox. " + + "Requires X-Api-Key matching this sandbox's key." + ) + public FeedbackAggregateResponse getFeedback(@PathParam("uuid") String uuidStr, + @HeaderParam("X-Api-Key") String apiKey) { + UUID id = parseUuid(uuidStr); + SandboxEntity sb = sandboxService.requireAuth(id, apiKey); + return sandboxService.getAggregatedFeedback(sb); + } + + @PATCH + @Path("/admin/{uuid}/tier") + @Operation(summary = "Promote sandbox tier (admin only)", + description = "Changes a sandbox tier, updating rate limits, caps, and expiry. " + + "Requires X-Admin-Key matching the global registry admin key.") + public Response promoteTier(@PathParam("uuid") String uuidStr, + @HeaderParam("X-Admin-Key") String adminKey, + Map body) { + if (adminApiKey.isBlank() || !adminApiKey.equals(adminKey)) { + return Response.status(401) + .entity(Map.of("message", "Invalid or missing admin key")) + .build(); + } + String newTier = body == null ? null : body.get("tier"); + if (newTier == null || newTier.isBlank()) { + return Response.status(400) + .entity(Map.of("message", "Body must contain 'tier'")) + .build(); + } + UUID id = parseUuid(uuidStr); + SandboxEntity sb = sandboxService.requireById(id); + sandboxService.promoteTier(sb, newTier.toUpperCase()); + return Response.ok(Map.of( + "sandboxId", sb.id.toString(), + "tier", sb.tier, + "expiresAt", sb.expiresAt.toString(), + "ratePerMinute", sb.ratePerMinute)) + .build(); + } + + @PATCH + @Path("/{uuid}/extend") + @SecurityRequirement(name = "SandboxMaintenanceKey") + @Operation( + summary = "Extend sandbox expiry", + description = "Resets the expiry to now + tier lifetime (e.g. 30 days for FREE). " + + "Can be called on an already-expired sandbox to reactivate it. " + + "Requires X-Maintenance-Key returned at registration." + ) + public Response extend(@PathParam("uuid") String uuidStr, + @HeaderParam("X-Maintenance-Key") String maintenanceKey) { + UUID id = parseUuid(uuidStr); + SandboxEntity sb = sandboxService.requireMaintenanceAuth(id, maintenanceKey); + java.time.Instant newExpiry = sandboxService.extendExpiry(sb); + return Response.ok(Map.of( + "expiresAt", newExpiry.toString(), + "_links", Map.of("sandbox", baseUrl + "/sandbox/" + sb.id))) + .build(); + } + + @PATCH + @Path("/{uuid}/api-key") + @SecurityRequirement(name = "SandboxMaintenanceKey") + @Operation( + summary = "Rotate the sandbox API key", + description = "Invalidates the current API key and issues a new one. " + + "All agents must be updated with the new key. " + + "The new key is shown exactly once — store it immediately. " + + "Requires X-Maintenance-Key returned at registration." + ) + public Response rotateApiKey(@PathParam("uuid") String uuidStr, + @HeaderParam("X-Maintenance-Key") String maintenanceKey) { + UUID id = parseUuid(uuidStr); + SandboxEntity sb = sandboxService.requireMaintenanceAuth(id, maintenanceKey); + String newKey = sandboxService.rotateApiKey(sb); + return Response.ok(Map.of( + "apiKey", newKey, + "message", "New API key issued. Store it immediately — it will not be shown again.", + "_links", Map.of("sandbox", baseUrl + "/sandbox/" + sb.id))) + .build(); + } + + // ── Private ─────────────────────────────────────────────────────────────── + + /** Returns 404 (not 400) for an unparseable UUID — it simply doesn't exist as a resource. */ + private UUID parseUuid(String s) { + try { + return UUID.fromString(s); + } catch (IllegalArgumentException e) { + throw new WebApplicationException( + Response.status(404).entity(Map.of("message", "Sandbox not found")).build()); + } + } +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/resource/ServiceResource.java b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/ServiceResource.java index d8eacb9..cddb369 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/resource/ServiceResource.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/ServiceResource.java @@ -1,5 +1,6 @@ package org.botstandards.apix.registry.resource; +import io.micrometer.core.instrument.MeterRegistry; import jakarta.inject.Inject; import jakarta.validation.Valid; import jakarta.ws.rs.*; @@ -12,6 +13,9 @@ import org.botstandards.apix.registry.dto.ServiceResponse; import org.botstandards.apix.registry.dto.VersionHistoryEntry; import org.botstandards.apix.registry.service.RegistryService; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; import java.net.URI; import java.util.List; @@ -26,10 +30,20 @@ public class ServiceResource { @Inject RegistryService registryService; + @Inject + MeterRegistry meters; + @ConfigProperty(name = "apix.api-key") String apiKey; @POST + @SecurityRequirement(name = "ApiKey") + @Operation( + summary = "Register or update a service", + description = "Registers a new service or updates an existing one (UPSERT keyed on endpoint URL). " + + "Requires the X-Api-Key header. Returns the UUID of the created or updated service. " + + "The service starts at stage=DEVELOPMENT and oLevel=UNVERIFIED unless specified in the payload." + ) public Response register(@Valid BsmPayload payload, @HeaderParam("X-Api-Key") String key) { requireKey(key); var service = registryService.register(payload); @@ -40,12 +54,23 @@ public class ServiceResource { @GET @Path("/{id}") + @Operation( + summary = "Get service by ID", + description = "Returns the full service record including BSM payload, verification level, liveness status, and lifecycle metadata." + ) public ServiceResponse getById(@PathParam("id") UUID id) { return ServiceResponse.from(registryService.requireById(id)); } @PATCH @Path("/{id}") + @SecurityRequirement(name = "ApiKey") + @Operation( + summary = "Update service metadata", + description = "Partially updates a service record. Only fields present in the request body are changed. " + + "Stage transitions (e.g. DEVELOPMENT → PRODUCTION, PRODUCTION → DEPRECATED) are validated: " + + "transitioning to DEPRECATED requires sunsetAt to be set; DECOMMISSIONED requires sunsetAt to have passed." + ) public ServiceResponse patch(@PathParam("id") UUID id, ServicePatchRequest req, @HeaderParam("X-Api-Key") String key) { @@ -54,19 +79,54 @@ public class ServiceResource { } @GET - public List search(@QueryParam("capability") String capability, - @QueryParam("stage") String stage) { + @Operation( + summary = "Search services by capability and optional extension properties", + description = "Returns services matching the given capability keyword. " + + "Omitting ?stage= defaults to PRODUCTION only — this is the standard agent query. " + + "Use ?stage=DEVELOPMENT or ?stage=BETA to discover pre-production services. " + + "Capability values are lowercase kebab-case strings defined by the registrant (e.g. nlp, translation, speech-to-text). " + + "The search matches exact capability tokens, not substrings. " + + "Use ?property=key:value to filter by custom extension properties stored in the service's 'extensions' object. " + + "Multiple ?property= parameters are ANDed together. " + + "Example: ?capability=translation&property=region:eu&property=dataResidency:DE" + ) + public List search( + @Parameter(description = "Capability keyword to search for (e.g. nlp, translation, speech-to-text). Required.", example = "nlp") + @QueryParam("capability") String capability, + @Parameter(description = "Lifecycle stage filter. Defaults to PRODUCTION if omitted. Valid values: DEVELOPMENT, BETA, PRODUCTION, DEPRECATED.", example = "PRODUCTION") + @QueryParam("stage") String stage, + @Parameter(description = "Extension property filter in key:value format. Matches against the service's extensions object. Repeatable for AND logic.", example = "region:eu") + @QueryParam("property") List properties) { if (capability == null || capability.isBlank()) { throw new BadRequestException("capability query parameter is required"); } - return registryService.search(capability, stage).stream() + var results = registryService.search(capability, stage, properties).stream() .map(ServiceResponse::from) .toList(); + + String stageTag = (stage != null && !stage.isBlank()) ? stage.toUpperCase() : "PRODUCTION"; + meters.counter("apix.search.services", + "capability", tv(capability), + "stage", stageTag) + .increment(); + meters.summary("apix.search.result_count", + "resource", "services", + "capability", tv(capability)) + .record(results.size()); + + return results; } @PATCH @Path("/{id}/olevel") + @SecurityRequirement(name = "ApiKey") + @Operation( + summary = "Set verification level", + description = "Assigns an O-level to a service after the registry operator has completed verification. " + + "O-levels progress from UNVERIFIED → IDENTITY_VERIFIED → LEGAL_ENTITY_VERIFIED → HYGIENE_VERIFIED → OPERATIONALLY_VERIFIED → AUDITED." + ) public ServiceResponse setOLevel(@PathParam("id") UUID id, + @Parameter(description = "Target O-level value.", example = "IDENTITY_VERIFIED") @QueryParam("level") String level, @HeaderParam("X-Api-Key") String key) { requireKey(key); @@ -76,10 +136,20 @@ public class ServiceResource { @GET @Path("/{id}/replacements") + @Operation( + summary = "Find replacement services", + description = "Returns services that declare themselves as replacements for the given service ID. " + + "Use this when a service is DEPRECATED or DECOMMISSIONED to find the recommended migration target. " + + "Optionally filter by minimum O-level, IoT readiness, device class, or protocol." + ) public Response getReplacements(@PathParam("id") UUID id, + @Parameter(description = "Minimum O-level of replacement candidates.", example = "IDENTITY_VERIFIED") @QueryParam("minOLevel") String minOLevel, + @Parameter(description = "If true, only return services with an IoT profile.") @QueryParam("iotReady") Boolean iotReady, + @Parameter(description = "Filter by IoT device class (e.g. sensor, actuator, gateway).") @QueryParam("deviceClass") String deviceClass, + @Parameter(description = "Filter by IoT protocol (e.g. MQTT, AMQP, HTTP).") @QueryParam("protocol") String protocol) { ReplacementsResponse body = registryService.getReplacements(id, minOLevel, iotReady, deviceClass, protocol); return Response.ok(body) @@ -89,10 +159,20 @@ public class ServiceResource { @GET @Path("/{id}/history") + @Operation( + summary = "Get version history", + description = "Returns the audit trail of all changes to a service record — registrations, BSM updates, stage transitions, O-level assignments, and sunset declarations — in reverse chronological order." + ) public List getHistory(@PathParam("id") UUID id) { return registryService.getHistory(id); } + // Cap tag values to prevent high-cardinality explosion from malformed inputs + private static String tv(String v) { + if (v == null || v.isBlank()) return "_none"; + return v.length() > 64 ? v.substring(0, 64) : v; + } + private void requireKey(String provided) { if (!apiKey.equals(provided)) { throw new NotAuthorizedException( diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/service/GeoService.java b/apix-registry/src/main/java/org/botstandards/apix/registry/service/GeoService.java new file mode 100644 index 0000000..7a205d1 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/service/GeoService.java @@ -0,0 +1,108 @@ +package org.botstandards.apix.registry.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.logging.Logger; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Optional; + +@ApplicationScoped +public class GeoService { + + private static final Logger LOG = Logger.getLogger(GeoService.class); + + @Inject + ObjectMapper mapper; + + private final HttpClient http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(3)) + .build(); + + /** + * Geocodes a free-text location string (e.g. "Berlin, Germany") to lat/lon via Nominatim. + * Returns empty if the location is null, blank, or the lookup fails. + */ + public Optional geocodeLocation(String location) { + if (location == null || location.isBlank()) return Optional.empty(); + try { + String encoded = URLEncoder.encode(location.trim(), StandardCharsets.UTF_8); + HttpRequest req = HttpRequest.newBuilder( + URI.create("https://nominatim.openstreetmap.org/search?q=" + encoded + "&format=json&limit=1")) + .header("User-Agent", "APIX-Registry/1.0 (api-index.org)") + .timeout(Duration.ofSeconds(5)) + .GET() + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() != 200) return Optional.empty(); + JsonNode root = mapper.readTree(resp.body()); + if (root.isEmpty()) return Optional.empty(); + JsonNode first = root.get(0); + return Optional.of(new double[]{ + first.get("lat").asDouble(), + first.get("lon").asDouble() + }); + } catch (Exception e) { + LOG.warnf("Geocoding failed for '%s': %s", location, e.getMessage()); + return Optional.empty(); + } + } + + /** + * Resolves a client IP (from X-Forwarded-For) to lat/lon via ip-api.com. + * Returns empty for private/loopback IPs, null input, or lookup failure. + * Raw IP is passed to ip-api.com only; the returned coordinates are what gets persisted. + */ + public Optional geolocateIp(String xForwardedFor) { + String ip = extractClientIp(xForwardedFor); + if (ip == null || isPrivateOrLoopback(ip)) return Optional.empty(); + try { + HttpRequest req = HttpRequest.newBuilder( + URI.create("http://ip-api.com/json/" + ip + "?fields=status,lat,lon")) + .timeout(Duration.ofSeconds(3)) + .GET() + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() != 200) return Optional.empty(); + JsonNode node = mapper.readTree(resp.body()); + if (!"success".equals(node.path("status").asText())) return Optional.empty(); + return Optional.of(new double[]{ + node.get("lat").asDouble(), + node.get("lon").asDouble() + }); + } catch (Exception e) { + LOG.debugf("IP geolocation failed for %s: %s", ip, e.getMessage()); + return Optional.empty(); + } + } + + /** Takes the leftmost IP from a comma-separated X-Forwarded-For header. */ + public static String extractClientIp(String xForwardedFor) { + if (xForwardedFor == null || xForwardedFor.isBlank()) return null; + return xForwardedFor.split(",")[0].trim(); + } + + static boolean isPrivateOrLoopback(String ip) { + if (ip.equals("::1") || ip.startsWith("127.") || ip.equalsIgnoreCase("localhost")) return true; + if (ip.startsWith("10.") || ip.startsWith("192.168.")) return true; + // 172.16.0.0/12 + if (ip.startsWith("172.")) { + String[] parts = ip.split("\\."); + if (parts.length >= 2) { + try { + int second = Integer.parseInt(parts[1]); + if (second >= 16 && second <= 31) return true; + } catch (NumberFormatException ignored) {} + } + } + return false; + } +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/service/OrganizationService.java b/apix-registry/src/main/java/org/botstandards/apix/registry/service/OrganizationService.java index 50d6e5c..78508c0 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/service/OrganizationService.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/service/OrganizationService.java @@ -594,7 +594,7 @@ public class OrganizationService { private void resetTanCountIfNeeded(OrganizationEntity org, Instant now) { if (org.tanLastRequestedAt != null - && Duration.between(org.tanLastRequestedAt, now).toHours() >= 24) { + && Duration.between(org.tanLastRequestedAt, now).compareTo(Duration.ofHours(24)) > 0) { org.tanRequestCount24h = 0; } } diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/service/RegistryService.java b/apix-registry/src/main/java/org/botstandards/apix/registry/service/RegistryService.java index daa7b61..b61a59a 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/service/RegistryService.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/service/RegistryService.java @@ -22,6 +22,7 @@ import org.botstandards.apix.registry.entity.ServiceVersionEntity; import java.time.Instant; import java.util.*; import java.util.stream.Stream; +import jakarta.persistence.Query; @ApplicationScoped public class RegistryService { @@ -35,7 +36,7 @@ public class RegistryService { @Transactional public ServiceEntity register(BsmPayload payload) { long existing = ((Number) em.createNativeQuery( - "SELECT COUNT(*) FROM services WHERE endpoint_url = :url") + "SELECT COUNT(*) FROM services WHERE endpoint_url = :url AND sandbox_id IS NULL") .setParameter("url", payload.endpoint()) .getSingleResult()).longValue(); if (existing > 0) { @@ -126,20 +127,42 @@ public class RegistryService { } @SuppressWarnings("unchecked") - public List search(String capability, String stage) { + public List search(String capability, String stage, List properties) { ServiceStage targetStage = stage != null ? ServiceStage.valueOf(stage.toUpperCase()) : ServiceStage.PRODUCTION; - return em.createNativeQuery( + List props = parsePropertyFilters(properties); + StringBuilder sql = new StringBuilder( "SELECT s.* FROM services s " + "WHERE s.bsm_payload @> jsonb_build_object('capabilities', jsonb_build_array(:cap)) " + "AND s.registry_status = 'ACTIVE' " + - "AND s.service_stage = :stage", - ServiceEntity.class) + "AND s.service_stage = :stage " + + "AND s.sandbox_id IS NULL"); + for (int i = 0; i < props.size(); i++) { + sql.append(" AND s.bsm_payload -> 'extensions' ->> :propKey").append(i) + .append(" = :propValue").append(i); + } + + Query q = em.createNativeQuery(sql.toString(), ServiceEntity.class) .setParameter("cap", capability) - .setParameter("stage", targetStage.name()) - .getResultList(); + .setParameter("stage", targetStage.name()); + for (int i = 0; i < props.size(); i++) { + q.setParameter("propKey" + i, props.get(i)[0]); + q.setParameter("propValue" + i, props.get(i)[1]); + } + return q.getResultList(); + } + + static List parsePropertyFilters(List properties) { + if (properties == null || properties.isEmpty()) return List.of(); + List result = new ArrayList<>(); + for (String p : properties) { + int colon = p.indexOf(':'); + if (colon < 1) continue; + result.add(new String[]{ p.substring(0, colon), p.substring(colon + 1) }); + } + return result; } @SuppressWarnings("unchecked") @@ -190,6 +213,50 @@ public class RegistryService { ); } + public ServiceEntity requireDeviceById(UUID id) { + ServiceEntity e = requireById(id); + if (e.iotProfile == null) { + throw new NotFoundException("Device service not found: " + id); + } + return e; + } + + @SuppressWarnings("unchecked") + public List searchDevices(String capability, String deviceClass, String protocol) { + StringBuilder sql = new StringBuilder( + "SELECT s.* FROM services s " + + "INNER JOIN iot_profiles ip ON ip.service_id = s.id " + + "WHERE s.registry_status = 'ACTIVE' AND s.service_stage = 'PRODUCTION'"); + if (capability != null) { + sql.append(" AND s.bsm_payload @> jsonb_build_object('capabilities', jsonb_build_array(:cap))"); + } + jakarta.persistence.Query q = em.createNativeQuery(sql.toString(), ServiceEntity.class); + if (capability != null) q.setParameter("cap", capability); + + Stream stream = ((List) q.getResultList()).stream(); + if (deviceClass != null) { + stream = stream.filter(s -> s.iotProfile.deviceClasses != null + && s.iotProfile.deviceClasses.contains(deviceClass)); + } + if (protocol != null) { + stream = stream.filter(s -> s.iotProfile.protocols != null + && s.iotProfile.protocols.contains(protocol)); + } + return stream.toList(); + } + + public long countAll() { + return ((Number) em.createNativeQuery( + "SELECT COUNT(*) FROM services WHERE registry_status = 'ACTIVE' AND sandbox_id IS NULL") + .getSingleResult()).longValue(); + } + + public long countLive() { + return ((Number) em.createNativeQuery( + "SELECT COUNT(*) FROM services WHERE registry_status = 'ACTIVE' AND liveness_status = 'UP' AND sandbox_id IS NULL") + .getSingleResult()).longValue(); + } + public List getHistory(UUID id) { requireById(id); @@ -281,7 +348,8 @@ public class RegistryService { r.locked() != null ? r.locked() : old.locked(), r.sunsetAt() != null ? r.sunsetAt() : old.sunsetAt(), r.migrationGuideUrl() != null ? r.migrationGuideUrl() : old.migrationGuideUrl(), - r.replacesServiceIds() != null ? r.replacesServiceIds() : old.replacesServiceIds() + r.replacesServiceIds() != null ? r.replacesServiceIds() : old.replacesServiceIds(), + old.extensions() ); if (r.endpoint() != null) e.endpointUrl = r.endpoint(); if (r.registrantOrgType() != null) e.registrantOrgType = r.registrantOrgType(); diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/service/SandboxService.java b/apix-registry/src/main/java/org/botstandards/apix/registry/service/SandboxService.java new file mode 100644 index 0000000..d9d80d3 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/service/SandboxService.java @@ -0,0 +1,582 @@ +package org.botstandards.apix.registry.service; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; +import org.botstandards.apix.common.*; +import org.botstandards.apix.registry.dto.*; +import org.botstandards.apix.registry.dto.SandboxLinks.LinkRef; +import org.botstandards.apix.registry.entity.SandboxEntity; +import org.botstandards.apix.registry.entity.ServiceEntity; +import org.botstandards.apix.registry.entity.ServiceVersionEntity; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.HexFormat; + +@ApplicationScoped +public class SandboxService { + + public static final String EVENT_SERVICE_REGISTERED = "SERVICE_REGISTERED"; + public static final String EVENT_SERVICE_LISTED = "SERVICE_LISTED"; + public static final String EVENT_SERVICE_SEARCHED = "SERVICE_SEARCHED"; + public static final String EVENT_SANDBOX_VIEWED = "SANDBOX_VIEWED"; + + @Inject + EntityManager em; + + @Inject + GeoService geoService; + + @ConfigProperty(name = "apix.registry.base-url") + String baseUrl; + + @ConfigProperty(name = "apix.portal.base-url", defaultValue = "") + String portalBaseUrl; + + @Transactional + public SandboxCreationResult create(String name, String contactEmail, String location) { + String plainKey = generateKey("apix_sb_"); + String plainMaintenanceKey = generateKey("apix_mk_"); + + SandboxEntity sandbox = new SandboxEntity(); + sandbox.id = UUID.randomUUID(); + sandbox.name = name; + sandbox.contactEmail = contactEmail; + sandbox.apiKeyHash = hash(plainKey); + sandbox.maintenanceKeyHash = hash(plainMaintenanceKey); + sandbox.tier = "FREE"; + sandbox.ratePerMinute = ratePerMinute("FREE"); + sandbox.maxServices = maxServices("FREE"); + sandbox.maxOrgs = maxOrgs("FREE"); + sandbox.createdAt = Instant.now(); + sandbox.expiresAt = sandbox.createdAt.plus(lifetimeDays("FREE"), ChronoUnit.DAYS); + + if (location != null && !location.isBlank()) { + sandbox.registrarLocation = location.trim(); + geoService.geocodeLocation(location).ifPresent(coords -> { + sandbox.registrarLat = coords[0]; + sandbox.registrarLon = coords[1]; + }); + } + + em.persist(sandbox); + return new SandboxCreationResult(sandbox, plainKey, plainMaintenanceKey); + } + + /** Records an agent visit geo-point. Raw IP is resolved and discarded; only coordinates are stored. */ + @Transactional + public void recordAgentVisit(String sandboxId, String xForwardedFor) { + geoService.geolocateIp(xForwardedFor).ifPresent(coords -> + em.createNativeQuery( + "INSERT INTO sandbox_agent_visits (id, sandbox_id, agent_lat, agent_lon, visited_at) " + + "VALUES (gen_random_uuid(), :sid, :lat, :lon, now())") + .setParameter("sid", sandboxId) + .setParameter("lat", coords[0]) + .setParameter("lon", coords[1]) + .executeUpdate() + ); + } + + /** Returns the sandbox if the key is valid and not expired, null if key unknown. */ + public SandboxEntity findByKey(String plainKey) { + if (plainKey == null || plainKey.isBlank()) return null; + String keyHash = hash(plainKey); + List results = em.createQuery( + "FROM SandboxEntity s WHERE s.apiKeyHash = :hash", + SandboxEntity.class) + .setParameter("hash", keyHash) + .getResultList(); + if (results.isEmpty()) return null; + SandboxEntity sandbox = results.get(0); + if (Instant.now().isAfter(sandbox.expiresAt)) return null; + return sandbox; + } + + public SandboxEntity requireById(UUID id) { + SandboxEntity s = em.find(SandboxEntity.class, id); + if (s == null) { + throw new WebApplicationException( + Response.status(404).entity(Map.of("message", "Sandbox not found")).build()); + } + return s; + } + + /** Validates that the presented key belongs to this sandbox UUID. */ + public SandboxEntity requireAuth(UUID id, String plainKey) { + SandboxEntity sandbox = requireById(id); + if (plainKey == null || !sandbox.apiKeyHash.equals(hash(plainKey))) { + throw new WebApplicationException( + Response.status(401) + .entity(Map.of("message", "Invalid or missing sandbox API key")) + .build()); + } + if (Instant.now().isAfter(sandbox.expiresAt)) { + throw new WebApplicationException( + Response.status(402) + .entity(Map.of("message", "Sandbox has expired — upgrade your tier to continue")) + .build()); + } + return sandbox; + } + + /** Validates the maintenance key; does NOT check expiry — owner must be able to extend an expired sandbox. */ + public SandboxEntity requireMaintenanceAuth(UUID id, String plainMaintenanceKey) { + SandboxEntity sandbox = requireById(id); + if (plainMaintenanceKey == null || !sandbox.maintenanceKeyHash.equals(hash(plainMaintenanceKey))) { + throw new WebApplicationException( + Response.status(401) + .entity(Map.of("message", "Invalid or missing maintenance key")) + .build()); + } + return sandbox; + } + + /** Extends sandbox expiry to now + tier lifetime. Returns the new expiresAt. */ + @Transactional + public Instant extendExpiry(SandboxEntity sb) { + sb.expiresAt = Instant.now().plus(lifetimeDays(sb.tier), ChronoUnit.DAYS); + em.merge(sb); + return sb.expiresAt; + } + + /** Upgrades a sandbox to the given tier, recalculating rate limits, caps, and expiry. */ + @Transactional + public void promoteTier(SandboxEntity sb, String newTier) { + sb.tier = newTier; + sb.ratePerMinute = ratePerMinute(newTier); + sb.maxServices = maxServices(newTier); + sb.maxOrgs = maxOrgs(newTier); + sb.expiresAt = Instant.now().plus(lifetimeDays(newTier), ChronoUnit.DAYS); + em.merge(sb); + } + + /** Replaces the API key with a freshly generated one. Returns the new plaintext key (shown once). */ + @Transactional + public String rotateApiKey(SandboxEntity sb) { + String newKey = generateKey("apix_sb_"); + sb.apiKeyHash = hash(newKey); + em.merge(sb); + return newKey; + } + + @Transactional + public ServiceEntity registerService(String sandboxId, BsmPayload payload) { + SandboxEntity sandbox = em.find(SandboxEntity.class, UUID.fromString(sandboxId)); + if (sandbox == null) throw new WebApplicationException( + Response.status(404).entity(Map.of("message", "Sandbox not found")).build()); + if (sandbox.maxServices != null) { + long current = ((Number) em.createNativeQuery( + "SELECT COUNT(*) FROM services WHERE sandbox_id = :sid AND registry_status = 'ACTIVE'") + .setParameter("sid", sandboxId) + .getSingleResult()).longValue(); + if (current >= sandbox.maxServices) { + throw new WebApplicationException(Response.status(429) + .entity(Map.of( + "message", "Service limit reached for this sandbox tier", + "limit", sandbox.maxServices, + "tier", sandbox.tier)) + .build()); + } + } + Instant now = Instant.now(); + ServiceEntity service = new ServiceEntity(); + service.id = UUID.randomUUID(); + service.sandboxId = sandboxId; + service.endpointUrl = payload.endpoint(); + service.bsmPayload = payload; + service.olevel = OLevel.UNVERIFIED; + service.livenessStatus = LivenessStatus.PENDING; + service.registeredAt = now; + service.registrantOrgType = payload.registrantOrgType() != null ? payload.registrantOrgType() : OrgType.INDIVIDUAL; + service.serviceStage = payload.serviceStage() != null ? payload.serviceStage() : ServiceStage.DEVELOPMENT; + service.registryStatus = RegistryStatus.ACTIVE; + service.version = 1; + + ServiceVersionEntity snapshot = new ServiceVersionEntity(); + snapshot.id = UUID.randomUUID(); + snapshot.serviceId = service.id; + snapshot.version = 1; + snapshot.recordedAt = now; + snapshot.changeType = ChangeType.REGISTERED; + snapshot.bsmPayload = payload; + snapshot.registrantOrgType = service.registrantOrgType; + snapshot.olevel = service.olevel; + snapshot.serviceStage = service.serviceStage; + snapshot.registryStatus = service.registryStatus; + + em.persist(service); + em.persist(snapshot); + return service; + } + + @SuppressWarnings("unchecked") + public List listServices(String sandboxId) { + return ((List) em.createNativeQuery( + "SELECT s.* FROM services s WHERE s.sandbox_id = :sid AND s.registry_status = 'ACTIVE'", + ServiceEntity.class) + .setParameter("sid", sandboxId) + .getResultList()) + .stream() + .map(ServiceResponse::from) + .toList(); + } + + @SuppressWarnings("unchecked") + public List searchServices(String sandboxId, String capability, String stage, + List properties) { + ServiceStage targetStage = stage != null + ? ServiceStage.valueOf(stage.toUpperCase()) + : ServiceStage.DEVELOPMENT; + + List props = RegistryService.parsePropertyFilters(properties); + StringBuilder sql = new StringBuilder( + "SELECT s.* FROM services s " + + "WHERE s.sandbox_id = :sid " + + "AND s.registry_status = 'ACTIVE' " + + "AND s.service_stage = :stage " + + "AND s.bsm_payload @> jsonb_build_object('capabilities', jsonb_build_array(:cap))"); + for (int i = 0; i < props.size(); i++) { + sql.append(" AND s.bsm_payload -> 'extensions' ->> :propKey").append(i) + .append(" = :propValue").append(i); + } + + jakarta.persistence.Query q = em.createNativeQuery(sql.toString(), ServiceEntity.class) + .setParameter("sid", sandboxId) + .setParameter("stage", targetStage.name()) + .setParameter("cap", capability); + for (int i = 0; i < props.size(); i++) { + q.setParameter("propKey" + i, props.get(i)[0]); + q.setParameter("propValue" + i, props.get(i)[1]); + } + return ((List) q.getResultList()).stream() + .map(ServiceResponse::from) + .toList(); + } + + // ── Telemetry ───────────────────────────────────────────────────────────── + + @Transactional + public void recordUsage(String sandboxId, String eventType) { + em.createNativeQuery( + "INSERT INTO sandbox_usage_stats (sandbox_id, event_type, request_count, last_requested_at) " + + "VALUES (:sid, :type, 1, now()) " + + "ON CONFLICT (sandbox_id, event_type) " + + "DO UPDATE SET request_count = sandbox_usage_stats.request_count + 1, " + + "last_requested_at = now()") + .setParameter("sid", sandboxId) + .setParameter("type", eventType) + .executeUpdate(); + } + + @SuppressWarnings("unchecked") + public SandboxTelemetryResponse getTelemetry(SandboxEntity sandbox, String baseUrl) { + List rows = em.createNativeQuery( + "SELECT event_type, request_count, last_requested_at " + + "FROM sandbox_usage_stats WHERE sandbox_id = :sid") + .setParameter("sid", sandbox.id.toString()) + .getResultList(); + + Map usage = new LinkedHashMap<>(); + Instant lastActivityAt = null; + for (Object[] row : rows) { + usage.put((String) row[0], ((Number) row[1]).longValue()); + Instant ts = row[2] instanceof java.sql.Timestamp t ? t.toInstant() : null; + if (ts != null && (lastActivityAt == null || ts.isAfter(lastActivityAt))) { + lastActivityAt = ts; + } + } + + String base = baseUrl + "/sandbox/" + sandbox.id; + SandboxLinks links = sandboxLinks(base, sandbox.id.toString()); + + return new SandboxTelemetryResponse( + sandbox.id.toString(), sandbox.name, sandbox.tier, + sandbox.ratePerMinute, sandbox.expiresAt, + sandbox.maxServices, sandbox.maxOrgs, + usage, lastActivityAt, links); + } + + @SuppressWarnings("unchecked") + public SandboxDashboardResponse getDashboard(UUID id) { + SandboxEntity sb = requireById(id); + + List usageRows = em.createNativeQuery( + "SELECT event_type, request_count, last_requested_at " + + "FROM sandbox_usage_stats WHERE sandbox_id = :sid") + .setParameter("sid", sb.id.toString()) + .getResultList(); + + Map usage = new LinkedHashMap<>(); + Instant lastActivityAt = null; + for (Object[] row : usageRows) { + usage.put((String) row[0], ((Number) row[1]).longValue()); + Instant ts = row[2] instanceof java.sql.Timestamp t ? t.toInstant() : null; + if (ts != null && (lastActivityAt == null || ts.isAfter(lastActivityAt))) { + lastActivityAt = ts; + } + } + + List visitRows = em.createNativeQuery( + "SELECT agent_lat, agent_lon, visited_at " + + "FROM sandbox_agent_visits WHERE sandbox_id = :sid " + + "ORDER BY visited_at DESC LIMIT 200") + .setParameter("sid", sb.id.toString()) + .getResultList(); + + List visits = visitRows.stream() + .map(row -> new SandboxDashboardResponse.AgentVisit( + ((Number) row[0]).doubleValue(), + ((Number) row[1]).doubleValue(), + row[2] instanceof java.sql.Timestamp t ? t.toInstant() : Instant.now())) + .toList(); + + return new SandboxDashboardResponse( + sb.id.toString(), sb.name, sb.tier, sb.ratePerMinute, + sb.maxServices, sb.maxOrgs, sb.createdAt, sb.expiresAt, + sb.registrarLocation, sb.registrarLat, sb.registrarLon, + usage, lastActivityAt, visits); + } + + // ── Feedback ────────────────────────────────────────────────────────────── + + @Transactional + public void submitFeedback(String sandboxId, FeedbackSubmissionRequest req) { + Map valid = new LinkedHashMap<>(); + for (var entry : req.scores().entrySet()) { + if (KNOWN_DIMENSIONS.stream().anyMatch(d -> d.key().equals(entry.getKey()))) { + int v = entry.getValue(); + if (v < 0 || v > 10) { + throw new WebApplicationException(Response.status(422) + .entity(Map.of("message", + "Score for '" + entry.getKey() + "' must be 0–10")) + .build()); + } + valid.put(entry.getKey(), v); + } + } + if (valid.isEmpty()) { + throw new WebApplicationException(Response.status(422) + .entity(Map.of("message", "No valid dimension keys submitted")) + .build()); + } + + try { + String scoresJson = new com.fasterxml.jackson.databind.ObjectMapper() + .writeValueAsString(valid); + em.createNativeQuery( + "INSERT INTO sandbox_feedback " + + "(sandbox_id, scores, agent_identifier, comment, model_identifier, model_provider) " + + "VALUES (:sid, :scores::jsonb, :agent, :comment, :modelId, :modelProvider)") + .setParameter("sid", sandboxId) + .setParameter("scores", scoresJson) + .setParameter("agent", req.agentIdentifier()) + .setParameter("comment", req.comment()) + .setParameter("modelId", req.modelIdentifier()) + .setParameter("modelProvider", req.modelProvider()) + .executeUpdate(); + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { + throw new IllegalStateException("Failed to serialise feedback scores", e); + } + } + + @SuppressWarnings("unchecked") + public FeedbackAggregateResponse getAggregatedFeedback(SandboxEntity sandbox) { + long total = ((Number) em.createNativeQuery( + "SELECT COUNT(*) FROM sandbox_feedback WHERE sandbox_id = :sid") + .setParameter("sid", sandbox.id.toString()) + .getSingleResult()).longValue(); + + List rows = em.createNativeQuery( + "SELECT kv.key, AVG((kv.value::text)::numeric), COUNT(*) " + + "FROM sandbox_feedback f, " + + "LATERAL jsonb_each(f.scores) AS kv(key, value) " + + "WHERE f.sandbox_id = :sid " + + "GROUP BY kv.key") + .setParameter("sid", sandbox.id.toString()) + .getResultList(); + + List scores = rows.stream() + .map(row -> { + String key = (String) row[0]; + double avg = ((Number) row[1]).doubleValue(); + int votes = ((Number) row[2]).intValue(); + String question = KNOWN_DIMENSIONS.stream() + .filter(d -> d.key().equals(key)) + .map(FeedbackDimension::question) + .findFirst() + .orElse(key); + return new FeedbackAggregateResponse.DimensionScore(key, question, avg, votes); + }) + .toList(); + + List providerRows = em.createNativeQuery( + "SELECT COALESCE(model_provider, 'unknown'), COUNT(*) " + + "FROM sandbox_feedback WHERE sandbox_id = :sid " + + "GROUP BY model_provider") + .setParameter("sid", sandbox.id.toString()) + .getResultList(); + + Map byProvider = new LinkedHashMap<>(); + for (Object[] row : providerRows) { + byProvider.put((String) row[0], ((Number) row[1]).intValue()); + } + + String base = baseUrl + "/sandbox/" + sandbox.id; + return new FeedbackAggregateResponse( + sandbox.id.toString(), sandbox.name, (int) total, scores, byProvider, + sandboxLinks(base, sandbox.id.toString())); + } + + public static FeedbackSchemaResponse feedbackSchema(String baseUrl) { + var scale = new FeedbackSchemaResponse.Scale(0, 10, "poor / unusable", "excellent / effortless"); + var schemaLinks = new FeedbackSchemaResponse.FeedbackSchemaLinks( + new SandboxLinks.LinkRef(baseUrl + "/sandbox/feedback-schema")); + return new FeedbackSchemaResponse( + "Agent experience dimensions for APIX sandbox usage. " + + "Submit scores to POST /sandbox/{uuid}/feedback. " + + "All dimensions are optional — submit only those relevant to your usage. " + + "The extension_property_coverage dimension is especially important: if the standard BSM fields " + + "were insufficient and you had to use ?property= queries or store data in extensions, " + + "a low score here signals a gap that should be standardised.", + scale, + KNOWN_DIMENSIONS, + schemaLinks); + } + + public SandboxLinks sandboxLinks(String base) { + return sandboxLinks(base, null); + } + + public SandboxLinks sandboxLinks(String base, String sandboxUuid) { + SandboxLinks.LinkRef dashboard = (sandboxUuid != null && !portalBaseUrl.isBlank()) + ? new SandboxLinks.LinkRef(portalBaseUrl + "/sandbox/" + sandboxUuid) + : null; + return new SandboxLinks( + new LinkRef(base), + new LinkRef(base + "/services"), + new LinkRef(base + "/services{?capability,stage,property}", true), + new LinkRef(base + "/feedback"), + new LinkRef(baseUrl + "/sandbox/feedback-schema"), + dashboard); + } + + // ── Statics ─────────────────────────────────────────────────────────────── + + public static final List KNOWN_DIMENSIONS = List.of( + new FeedbackDimension( + "hateoas_navigation", + "Was HATEOAS navigation usable without prior documentation?", + "completely lost", "navigated effortlessly"), + new FeedbackDimension( + "discovery_accuracy", + "Did capability search return relevant services for your intent?", + "irrelevant results", "exactly what I needed"), + new FeedbackDimension( + "trust_signal_clarity", + "Were service trust levels (O-level) useful for your decision-making?", + "confusing / irrelevant", "clear and decisive"), + new FeedbackDimension( + "schema_completeness", + "Was the service information (BSM) complete enough to proceed without guessing?", + "critical fields missing", "everything I needed was there"), + new FeedbackDimension( + "sandbox_setup", + "How easy was sandbox creation and first service registration?", + "blocked / confusing", "up and running immediately"), + new FeedbackDimension( + "rate_limit_adequacy", + "Was the rate limit adequate for your testing workload?", + "severely limiting", "no constraint felt"), + new FeedbackDimension( + "service_cap_adequacy", + "Was the service registration limit adequate for your testing needs?", + "hit the cap immediately", "never reached it"), + new FeedbackDimension( + "liveness_signal_accuracy", + "Did liveness status reflect the service's actual reachability?", + "liveness lied repeatedly", "always matched reality"), + new FeedbackDimension( + "error_message_quality", + "Were error responses informative enough to correct your request without guessing?", + "opaque / misleading", "clear and immediately actionable"), + new FeedbackDimension( + "extension_property_coverage", + "Did the standard BSM fields cover what you needed, or did you rely on custom extension properties for information that should be standardised?", + "had to use extensions for basic info", "standard fields covered everything") + ); + + public static int ratePerMinute(String tier) { + return switch (tier) { + case "STANDARD" -> 300; + case "PROFESSIONAL" -> 1_000; + case "COMMUNITY" -> 50_000; // stress-test tier + case "FOUNDER" -> 5_000; + case "DEMO" -> 10_000; // demo ecosystem + default -> 60; // FREE + }; + } + + /** NULL = unlimited. */ + public static Integer maxServices(String tier) { + return switch (tier) { + case "STANDARD" -> 50; + case "PROFESSIONAL" -> 200; + case "COMMUNITY" -> null; + case "FOUNDER" -> null; + case "DEMO" -> null; + default -> 10; // FREE + }; + } + + /** NULL = unlimited. */ + public static Integer maxOrgs(String tier) { + return switch (tier) { + case "STANDARD" -> 10; + case "PROFESSIONAL" -> 50; + case "COMMUNITY" -> null; + case "FOUNDER" -> null; + case "DEMO" -> null; + default -> 3; // FREE + }; + } + + public static long lifetimeDays(String tier) { + return switch (tier) { + case "STANDARD" -> 180; + case "PROFESSIONAL" -> 365; + case "COMMUNITY" -> 90; + case "FOUNDER" -> 36_500; // 100 years + case "DEMO" -> 36_500; // permanent — never purged + default -> 30; // FREE + }; + } + + private static String generateKey(String prefix) { + byte[] bytes = new byte[32]; + new SecureRandom().nextBytes(bytes); + return prefix + HexFormat.of().formatHex(bytes); + } + + private static String hash(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex( + digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + public record SandboxCreationResult(SandboxEntity sandbox, String plainKey, String plainMaintenanceKey) {} +} diff --git a/apix-registry/src/main/resources/application.properties b/apix-registry/src/main/resources/application.properties index 8fdb4dd..63114f0 100644 --- a/apix-registry/src/main/resources/application.properties +++ b/apix-registry/src/main/resources/application.properties @@ -19,6 +19,12 @@ quarkus.liquibase.change-log=db/changelog/db.changelog-master.xml # ── HTTP ────────────────────────────────────────────────────────────────────── quarkus.http.port=8180 +# ── Registry identity — used by IndexResource for HATEOAS links ─────────────── +apix.registry.base-url=${APIX_REGISTRY_BASE_URL:http://localhost:8180} +apix.registry.name=${APIX_REGISTRY_NAME:APIX Registry} +apix.portal.base-url=${APIX_PORTAL_BASE_URL:https://www.api-index.org} +apix.registry.description=${APIX_REGISTRY_DESCRIPTION:The open autonomous agent service discovery registry. Follow _links.services to browse, or _links.servicesSearch to filter by capability.} + # ── Security — API key for write endpoints ─────────────────────────────────── apix.api-key=${APIX_API_KEY:dev-insecure-key-change-in-prod} @@ -41,6 +47,22 @@ apix.mail.signing.public-key-base64=${APIX_MAIL_SIGNING_PUBLIC_KEY:} apix.mail.signing.kid=${APIX_MAIL_SIGNING_KID:dev} apix.sanctions.cache-path=${SANCTIONS_CACHE_PATH:./sanctions-cache} +# ── Cache ───────────────────────────────────────────────────────────────────── +# registry-index: caches GET / response. 60s TTL is acceptable — agents read the +# root for navigation links which are static; counts are informational only. +# CDN layer sits in front for edge caching. CDN choice is a governance decision: +# no founding member candidate may operate infrastructure over the registry. +# - Bunny.net (primary): European (Slovenia), 100+ PoPs, Africa + Asia-Pacific +# coverage, privacy values align with Swiss Stiftung model. No AI/agent play. +# - Fastly (secondary/fallback): independent US public company, no AI/agent play, +# built for API/JSON caching, used by GitHub and npm, strong developer trust. +# - DO NOT use Cloudflare (founding member target) or AWS CloudFront (AWS is a +# founding member target): operational infrastructure = governance leverage, +# regardless of what the founding charter says. +quarkus.cache.caffeine.registry-index.expire-after-write=60S +quarkus.cache.caffeine.registry-index.initial-capacity=1 +quarkus.cache.caffeine.registry-index.maximum-size=1 + # ── Logging ─────────────────────────────────────────────────────────────────── quarkus.log.level=${LOG_LEVEL:DEBUG} quarkus.log.console.json=false diff --git a/apix-registry/src/main/resources/db/changelog/changes/012-capabilities-gin-index.xml b/apix-registry/src/main/resources/db/changelog/changes/012-capabilities-gin-index.xml new file mode 100644 index 0000000..6b21d58 --- /dev/null +++ b/apix-registry/src/main/resources/db/changelog/changes/012-capabilities-gin-index.xml @@ -0,0 +1,22 @@ + + + + + + + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_services_capabilities + ON services USING GIN ((bsm_payload -> 'capabilities')); + + + DROP INDEX CONCURRENTLY IF EXISTS idx_services_capabilities; + + + + diff --git a/apix-registry/src/main/resources/db/changelog/changes/013-sandbox.xml b/apix-registry/src/main/resources/db/changelog/changes/013-sandbox.xml new file mode 100644 index 0000000..7b3fb8a --- /dev/null +++ b/apix-registry/src/main/resources/db/changelog/changes/013-sandbox.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CREATE UNIQUE INDEX uq_services_endpoint_production + ON services(endpoint_url) + WHERE sandbox_id IS NULL; + + + + + CREATE INDEX idx_services_sandbox_id + ON services(sandbox_id) + WHERE sandbox_id IS NOT NULL; + + + + + diff --git a/apix-registry/src/main/resources/db/changelog/changes/014-sandbox-usage.xml b/apix-registry/src/main/resources/db/changelog/changes/014-sandbox-usage.xml new file mode 100644 index 0000000..88eabff --- /dev/null +++ b/apix-registry/src/main/resources/db/changelog/changes/014-sandbox-usage.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apix-registry/src/main/resources/db/changelog/changes/015-sandbox-caps.xml b/apix-registry/src/main/resources/db/changelog/changes/015-sandbox-caps.xml new file mode 100644 index 0000000..49e7b80 --- /dev/null +++ b/apix-registry/src/main/resources/db/changelog/changes/015-sandbox-caps.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + UPDATE sandboxes SET max_services = 10, max_orgs = 3 WHERE tier = 'FREE'; + + + + diff --git a/apix-registry/src/main/resources/db/changelog/changes/016-sandbox-feedback.xml b/apix-registry/src/main/resources/db/changelog/changes/016-sandbox-feedback.xml new file mode 100644 index 0000000..54de031 --- /dev/null +++ b/apix-registry/src/main/resources/db/changelog/changes/016-sandbox-feedback.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apix-registry/src/main/resources/db/changelog/changes/017-feedback-model-info.xml b/apix-registry/src/main/resources/db/changelog/changes/017-feedback-model-info.xml new file mode 100644 index 0000000..7043e3e --- /dev/null +++ b/apix-registry/src/main/resources/db/changelog/changes/017-feedback-model-info.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apix-registry/src/main/resources/db/changelog/changes/018-sandbox-geo.xml b/apix-registry/src/main/resources/db/changelog/changes/018-sandbox-geo.xml new file mode 100644 index 0000000..de1cf66 --- /dev/null +++ b/apix-registry/src/main/resources/db/changelog/changes/018-sandbox-geo.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + diff --git a/apix-registry/src/main/resources/db/changelog/changes/019-sandbox-agent-visits.xml b/apix-registry/src/main/resources/db/changelog/changes/019-sandbox-agent-visits.xml new file mode 100644 index 0000000..3056a67 --- /dev/null +++ b/apix-registry/src/main/resources/db/changelog/changes/019-sandbox-agent-visits.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apix-registry/src/main/resources/db/changelog/changes/020-sandbox-uuid-routing.xml b/apix-registry/src/main/resources/db/changelog/changes/020-sandbox-uuid-routing.xml new file mode 100644 index 0000000..81cffaa --- /dev/null +++ b/apix-registry/src/main/resources/db/changelog/changes/020-sandbox-uuid-routing.xml @@ -0,0 +1,30 @@ + + + + + + + + TRUNCATE TABLE sandbox_agent_visits, sandbox_usage_stats, sandbox_feedback, sandboxes CASCADE; + + + + + + + + + + + + + + diff --git a/apix-registry/src/main/resources/db/changelog/changes/021-sandbox-maintenance-key.xml b/apix-registry/src/main/resources/db/changelog/changes/021-sandbox-maintenance-key.xml new file mode 100644 index 0000000..2bd9498 --- /dev/null +++ b/apix-registry/src/main/resources/db/changelog/changes/021-sandbox-maintenance-key.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + diff --git a/apix-registry/src/main/resources/db/changelog/changes/022-optional-email.xml b/apix-registry/src/main/resources/db/changelog/changes/022-optional-email.xml new file mode 100644 index 0000000..49a2011 --- /dev/null +++ b/apix-registry/src/main/resources/db/changelog/changes/022-optional-email.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/apix-registry/src/main/resources/db/changelog/db.changelog-master.xml b/apix-registry/src/main/resources/db/changelog/db.changelog-master.xml index bf14639..79b159a 100644 --- a/apix-registry/src/main/resources/db/changelog/db.changelog-master.xml +++ b/apix-registry/src/main/resources/db/changelog/db.changelog-master.xml @@ -16,5 +16,16 @@ + + + + + + + + + + + diff --git a/apix-registry/src/test/java/org/botstandards/apix/registry/service/GeoServiceTest.java b/apix-registry/src/test/java/org/botstandards/apix/registry/service/GeoServiceTest.java new file mode 100644 index 0000000..dddb153 --- /dev/null +++ b/apix-registry/src/test/java/org/botstandards/apix/registry/service/GeoServiceTest.java @@ -0,0 +1,63 @@ +package org.botstandards.apix.registry.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class GeoServiceTest { + + // ── extractClientIp ────────────────────────────────────────────────────── + + @Test + void extractClientIp_returnsFirstIp_whenMultiplePresent() { + assertThat(GeoService.extractClientIp("203.0.113.42, 10.0.0.1, 172.16.0.1")) + .isEqualTo("203.0.113.42"); + } + + @Test + void extractClientIp_trimsWhitespace() { + assertThat(GeoService.extractClientIp(" 198.51.100.7 , 10.0.0.2")) + .isEqualTo("198.51.100.7"); + } + + @Test + void extractClientIp_returnsNull_forNullInput() { + assertThat(GeoService.extractClientIp(null)).isNull(); + } + + @Test + void extractClientIp_returnsNull_forBlankInput() { + assertThat(GeoService.extractClientIp(" ")).isNull(); + } + + @Test + void extractClientIp_handlesStandaloneIp() { + assertThat(GeoService.extractClientIp("203.0.113.1")).isEqualTo("203.0.113.1"); + } + + // ── isPrivateOrLoopback ────────────────────────────────────────────────── + + @ParameterizedTest + @ValueSource(strings = { "127.0.0.1", "::1", "localhost", "10.0.0.1", "10.255.255.255", + "192.168.0.1", "192.168.255.254", "172.16.0.1", "172.31.255.255" }) + void isPrivateOrLoopback_returnsTrue_forPrivateAddresses(String ip) { + assertThat(GeoService.isPrivateOrLoopback(ip)).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = { "8.8.8.8", "203.0.113.1", "198.51.100.42", + "172.15.255.255", "172.32.0.1", "1.1.1.1" }) + void isPrivateOrLoopback_returnsFalse_forPublicAddresses(String ip) { + assertThat(GeoService.isPrivateOrLoopback(ip)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "172.16.0.1,true", "172.20.5.5,true", "172.31.0.0,true", + "172.15.0.1,false", "172.32.0.1,false" }) + void isPrivateOrLoopback_handles172Range(String ip, boolean expected) { + assertThat(GeoService.isPrivateOrLoopback(ip)).isEqualTo(expected); + } +} diff --git a/apix-spider/src/main/java/org/botstandards/apix/spider/SandboxCleanupJob.java b/apix-spider/src/main/java/org/botstandards/apix/spider/SandboxCleanupJob.java new file mode 100644 index 0000000..b002685 --- /dev/null +++ b/apix-spider/src/main/java/org/botstandards/apix/spider/SandboxCleanupJob.java @@ -0,0 +1,99 @@ +package org.botstandards.apix.spider; + +import io.quarkus.logging.Log; +import io.quarkus.scheduler.Scheduled; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; + +/** + * Purges expired sandboxes and all their associated data. + * Single concern: cleanup. No liveness probing, no reporting. + * + * Deletion order respects the service_versions → services FK. + * The sandbox child tables (feedback, usage_stats, agent_visits) have no + * active FK constraints after changeset 020, but are still cleaned up + * explicitly to avoid orphaned rows. + */ +@ApplicationScoped +public class SandboxCleanupJob { + + @Inject + DataSource dataSource; + + @Scheduled(cron = "${apix.spider.cleanup-cron:0 0 * * * ?}") + void purgeExpiredSandboxes() { + try (Connection conn = dataSource.getConnection()) { + conn.setAutoCommit(false); + try { + // DEMO tier sandboxes never expire — they are excluded from all deletes + long count = countExpired(conn); + if (count == 0) { + conn.rollback(); + return; + } + + // Only FREE sandboxes auto-expire; STANDARD+ are paid and stay until explicit cancellation + // service_versions references services.id — delete versions first + exec(conn, + "DELETE FROM service_versions sv " + + "USING services s, sandboxes sb " + + "WHERE sv.service_id = s.id " + + " AND s.sandbox_id = sb.id::text " + + " AND sb.expires_at < now() AND sb.tier = 'FREE'"); + + exec(conn, + "DELETE FROM services s " + + "USING sandboxes sb " + + "WHERE s.sandbox_id = sb.id::text " + + " AND sb.expires_at < now() AND sb.tier = 'FREE'"); + + exec(conn, + "DELETE FROM sandbox_feedback sf " + + "USING sandboxes sb " + + "WHERE sf.sandbox_id = sb.id::text " + + " AND sb.expires_at < now() AND sb.tier = 'FREE'"); + + exec(conn, + "DELETE FROM sandbox_usage_stats su " + + "USING sandboxes sb " + + "WHERE su.sandbox_id = sb.id::text " + + " AND sb.expires_at < now() AND sb.tier = 'FREE'"); + + exec(conn, + "DELETE FROM sandbox_agent_visits av " + + "USING sandboxes sb " + + "WHERE av.sandbox_id = sb.id::text " + + " AND sb.expires_at < now() AND sb.tier = 'FREE'"); + + exec(conn, "DELETE FROM sandboxes WHERE expires_at < now() AND tier = 'FREE'"); + + conn.commit(); + Log.infof("Purged %d expired sandbox(es)", count); + } catch (SQLException e) { + conn.rollback(); + Log.errorf(e, "Sandbox cleanup failed — transaction rolled back"); + } + } catch (SQLException e) { + Log.errorf(e, "Sandbox cleanup failed — could not obtain connection"); + } + } + + private static long countExpired(Connection conn) throws SQLException { + try (var stmt = conn.prepareStatement( + "SELECT COUNT(*) FROM sandboxes WHERE expires_at < now() AND tier = 'FREE'"); + var rs = stmt.executeQuery()) { + rs.next(); + return rs.getLong(1); + } + } + + private static void exec(Connection conn, String sql) throws SQLException { + try (var stmt = conn.prepareStatement(sql)) { + stmt.executeUpdate(); + } + } +} diff --git a/apix-spider/src/main/resources/application.properties b/apix-spider/src/main/resources/application.properties new file mode 100644 index 0000000..7cfa3ee --- /dev/null +++ b/apix-spider/src/main/resources/application.properties @@ -0,0 +1,10 @@ +quarkus.http.port=8082 +quarkus.smallrye-health.root-path=/q/health +quarkus.log.level=${LOG_LEVEL:INFO} + +# DB — spider connects to the same database; does NOT run Liquibase (registry owns schema) +quarkus.datasource.db-kind=postgresql +quarkus.datasource.jdbc.url=${DB_URL:jdbc:postgresql://localhost:5432/apix} +quarkus.datasource.username=${DB_USER:apix} +quarkus.datasource.password=${DB_PASSWORD:apix} +quarkus.hibernate-orm.database.generation=none diff --git a/docs/dns-migration-ionos-to-bunnynet.md b/docs/dns-migration-ionos-to-bunnynet.md new file mode 100644 index 0000000..f3d4e2f --- /dev/null +++ b/docs/dns-migration-ionos-to-bunnynet.md @@ -0,0 +1,278 @@ +# DNS Migration: IONOS → BunnyDNS + +Safe, zero-downtime migration of `api-index.org` from IONOS to BunnyDNS, +enabling apex CNAME support for the Bunny.net CDN. + +**Risk profile:** Low if the checklist is followed in order. The domain stays +fully operational at every step. IONOS remains the authoritative fallback +until you explicitly confirm the migration is complete. + +--- + +## Overview + +``` +Phase 0 — Audit Inventory every record in IONOS. Nothing changes. +Phase 1 — Reduce TTL Lower TTL to 300 s. Wait for old TTL to drain. +Phase 2 — Mirror Replicate all records into BunnyDNS. Verify. +Phase 3 — Nameservers Change NS at IONOS registrar → Bunny.net servers. +Phase 4 — CDN switch Replace A record with CNAME to Bunny.net CDN edge. +Phase 5 — Cleanup Confirm everything. IONOS zone stays intact as archive. +``` + +**Total calendar time:** 2–3 days minimum (TTL drain + propagation windows). +You can compress to ~24 h if IONOS allows TTL = 60 s and you monitor closely. + +--- + +## Phase 0 — Audit Existing Records + +**Do this before touching anything.** + +Log in to IONOS → Domains & SSL → `api-index.org` → DNS. + +Export or manually list every record. With no email on the domain the expected +records are minimal: + +| Type | Name | Value | Notes | +|------|------|-------|-------| +| A | `@` | VPS IP or IONOS parking IP | Migrate this | +| A / CNAME | `www` | same IP or alias | Migrate this | +| CNAME | `_domainconnect` | IONOS internal | **Do NOT migrate — IONOS-specific** | + +No MX, SPF, DKIM, or DMARC records exist (no email = no mail records). +This makes the migration low-risk: there is nothing here that can cause an +hours-long silent failure. The worst case is the website is briefly unreachable, +which resolves as soon as you revert the nameservers. + +**Identify the current TTL** for each record. IONOS default is often 3600 s (1 h). +That is the minimum wait time after reducing TTL before the change is globally drained. + +--- + +## Phase 1 — Reduce TTL + +Change every record's TTL in IONOS to **300 seconds** (5 minutes). + +IONOS UI path: DNS record → Edit → TTL field. +If IONOS does not allow TTL below 3600 on your plan, use 3600 — it just means +a longer wait in Phase 3. + +**After saving the reduced TTLs, wait for the old TTL to fully drain.** +If the old TTL was 3600 s (1 h), wait at least 1 hour before proceeding. +This ensures no resolver is caching the old TTL value. + +Verify the reduced TTL is live from an external resolver: +```bash +# Should show TTL=300 (or close to it) in the answer section +dig api-index.org A +noall +answer +dig api-index.org MX +noall +answer +``` + +Do not proceed to Phase 2 until the TTL shown in `dig` output matches your +reduced value. + +--- + +## Phase 2 — Mirror Records into BunnyDNS + +### 2a. Create the BunnyDNS zone + +1. Log in to Bunny.net → DNS → Add Zone +2. Enter `api-index.org` and confirm +3. Bunny.net assigns two nameservers (e.g. `kiki.bunny.net`, `coco.bunny.net`) — + note these for Phase 3 + +### 2b. Add every record from Phase 0 + +Replicate the full record list into BunnyDNS exactly — same name, same value, +same type. Use TTL = 300 for all records during migration. + +**Do not add:** +- `_domainconnect` — IONOS-internal, not needed +- IONOS parking/redirect entries — not needed + +**Do add:** +- A record `@` → VPS IP +- A record (or CNAME) `www` → VPS IP (or `api-index.org`) + +For the apex A record (`@`), add it pointing to the **Hetzner VPS IP** for now. +You will convert it to the CDN CNAME in Phase 4 — not before. + +### 2c. Verify the BunnyDNS zone before touching IONOS + +Use `dig` with BunnyDNS as the explicit resolver to query the zone before +nameserver delegation. Replace `kiki.bunny.net` with your assigned nameserver: + +```bash +# A record — should return VPS IP +dig @kiki.bunny.net api-index.org A +short + +# www +dig @kiki.bunny.net www.api-index.org A +short +``` + +Compare every answer against your Phase 0 audit. +**Do not proceed to Phase 3 until all records match.** + +--- + +## Phase 3 — Switch Nameservers at IONOS + +This is the single step that transfers authority. Once saved, resolvers will +gradually start querying BunnyDNS instead of IONOS. IONOS DNS zone remains +intact — you are only changing where resolvers are pointed, not deleting anything. + +### 3a. Change nameservers in IONOS + +IONOS UI path: Domains & SSL → `api-index.org` → Nameservers → Use custom nameservers + +Enter the two nameservers from BunnyDNS (e.g.): +``` +kiki.bunny.net +coco.bunny.net +``` + +Save. IONOS will show a warning that custom nameservers override IONOS DNS — confirm. + +### 3b. Wait for propagation + +Propagation is complete when global resolvers return the BunnyDNS IP for your domain. +This typically takes 15–60 minutes with TTL=300. It can take up to 48 hours in rare +cases (resolvers ignoring low TTLs). Monitor: + +```bash +# Repeat every few minutes — watch for the IONOS nameservers to disappear +dig api-index.org NS +short + +# Check from multiple global vantage points +# https://dnschecker.org/#A/api-index.org — paste in browser, check all green +``` + +**During propagation:** Some resolvers still use IONOS, some use BunnyDNS. +Both zones have identical records pointing to the VPS IP — so the site stays up +regardless of which nameserver a resolver hits. + +### 3c. Verify after propagation + +```bash +# NS records should now show Bunny.net nameservers from all resolvers +dig api-index.org NS +short + +# A record resolves to VPS IP +dig api-index.org A +short + +# End-to-end HTTPS +curl -sv https://api-index.org/ 2>&1 | grep -E "HTTP|certificate" +``` + +Do not proceed to Phase 4 until all checks pass. + +--- + +## Phase 4 — Switch Apex Record to CDN CNAME + +Only do this after Phase 3 is fully confirmed. + +### 4a. Run the Bunny.net CDN setup script + +If not already done, provision the pull zone: + +```bash +BUNNYNET_API_KEY=your-key \ +ORIGIN_URL=https:// \ +CUSTOM_HOSTNAME=api-index.org \ +SYSLOG_HOST= \ +INSTALL_CRON=true \ +./scripts/setup-bunnynet.sh +``` + +Note the CDN hostname printed at the end (e.g. `apix-registry.b-cdn.net`). + +### 4b. Replace the apex A record with a CNAME in BunnyDNS + +In BunnyDNS → `api-index.org` zone: + +1. Delete the `@` A record pointing to the VPS IP +2. Add a `CNAME` record: `@` → `apix-registry.b-cdn.net` (your CDN hostname) + +BunnyDNS supports CNAME at the apex via automatic flattening — this is why +you migrated here. + +### 4c. Verify CDN is serving the domain + +```bash +# A record now resolves to Bunny.net edge IP (not VPS IP) +dig api-index.org A +short + +# HTTPS still works +curl -sv https://api-index.org/ 2>&1 | grep HTTP + +# Second request should be a CDN cache HIT +curl -sI "https://api-index.org/services?capability=nlp" | grep -i cache +``` + +--- + +## Phase 5 — Cleanup and Archive + +**Do not delete the IONOS DNS zone.** Leave it intact as a ready-to-activate +fallback for at least 30 days. If something goes wrong after Phase 3, you can +revert by changing the nameservers back to IONOS in the registrar — the zone +still has all the correct records. + +After 30 days of stable operation: +- IONOS zone can be deleted or left (it costs nothing to keep) +- Document the Bunny.net nameservers in `.env` or `docs/` for future reference + +--- + +## Rollback Procedure + +At any phase before Phase 3 is complete: nothing has changed — no rollback needed. + +After Phase 3 (nameserver switch): +``` +IONOS → Domains → api-index.org → Nameservers → Use IONOS nameservers +``` +Propagation back takes 5–60 minutes with TTL=300. +The IONOS zone was never modified — it still has all records. + +After Phase 4 (CNAME switch): +1. In BunnyDNS: delete the CNAME `@`, re-add the A record `@` → VPS IP +2. The CDN is bypassed; traffic flows directly to VPS again + +--- + +## Checklist Summary + +``` +Phase 0 + [ ] Audit and export all records from IONOS (expected: A @ , A/CNAME www, _domainconnect) + [ ] Identify current TTL values + [ ] Confirm no MX records present (no email on domain) + +Phase 1 + [ ] Reduce all TTLs to 300 s in IONOS + [ ] Wait for old TTL to drain (at minimum the old TTL duration) + [ ] dig confirms TTL ~300 in answers + +Phase 2 + [ ] BunnyDNS zone created for api-index.org + [ ] A record @ and www replicated into BunnyDNS (no MX/TXT to worry about) + [ ] Verified via: dig @ api-index.org A and dig @ www.api-index.org A + +Phase 3 + [ ] Nameservers changed at IONOS registrar to BunnyDNS + [ ] Propagation monitored until global NS shows Bunny.net + [ ] HTTPS end-to-end test passes + +Phase 4 + [ ] CDN pull zone provisioned (setup-bunnynet.sh) + [ ] Apex A record replaced with CNAME in BunnyDNS + [ ] CDN cache HIT confirmed on second request + +Phase 5 + [ ] IONOS zone archived (do not delete for 30 days) + [ ] Bunny.net nameservers documented +``` diff --git a/docs/infrastructure-setup.md b/docs/infrastructure-setup.md new file mode 100644 index 0000000..c04bf04 --- /dev/null +++ b/docs/infrastructure-setup.md @@ -0,0 +1,793 @@ +# APIX Registry — Infrastructure Setup Guide + +Complete walkthrough for deploying the APIX registry to a Hetzner VPS with Bunny.net CDN, +Prometheus/Grafana observability, and live Loki telemetry. + +--- + +## Table of Contents + +1. [Architecture Overview](#1-architecture-overview) +2. [Prerequisites](#2-prerequisites) +3. [VPS Provisioning (Hetzner)](#3-vps-provisioning-hetzner) +4. [DNS Configuration](#4-dns-configuration) +5. [Server Bootstrap](#5-server-bootstrap) +6. [Application Build](#6-application-build) +7. [Environment Configuration](#7-environment-configuration) +8. [Deploy the Stack](#8-deploy-the-stack) +9. [Caddy TLS Reverse Proxy](#9-caddy-tls-reverse-proxy) +10. [Bunny.net CDN Setup](#10-bunnynet-cdn-setup) +11. [Live Telemetry: Promtail → Loki](#11-live-telemetry-promtail--loki) +12. [Grafana Dashboards](#12-grafana-dashboards) +13. [Weekly Analytics (Bunny.net Logs)](#13-weekly-analytics-bunnynet-logs) +14. [Verification Checklist](#14-verification-checklist) +15. [Routine Operations](#15-routine-operations) + +--- + +## 1. Architecture Overview + +``` +Internet + │ + ▼ +Bunny.net CDN (100+ PoPs, GDPR-compliant, European) + │ Cache-Control headers respected; query-string vary cache enabled + │ HIT: served from edge (~5ms). MISS: forwarded to VPS + │ + ▼ +Hetzner VPS (Helsinki / Falkenstein) + │ + ├── Caddy (80/443) ─── TLS termination, HTTPS redirect, rate limiting + │ │ + │ ├── registry:8180 — REST API (Quarkus, JVM) + │ ├── portal:8081 — Web UI (Quarkus, Qute templates) + │ └── grafana:3000 — Dashboards (internal access only) + │ + ├── db:5432 — PostgreSQL 16 (no public port) + ├── spider:8082 — Liveness checker (no public port) + ├── prometheus:9090 — Metrics scraper (no public port) + └── promtail:9080 — Syslog receiver → Loki (port 5514 open) + +Grafana Cloud (Loki) + │ Real-time: Bunny.net → TCP Syslog → Promtail → Loki → Grafana + └── Every CDN request visible within 1-2 seconds +``` + +**CDN governance constraint:** Cloudflare and AWS CloudFront must never be used. +Both are founding member candidates — operating infrastructure gives governance leverage +regardless of the founding charter. Approved providers: Bunny.net (primary), Fastly (fallback). + +--- + +## 2. Prerequisites + +### Local machine +| Tool | Version | Purpose | +|------|---------|---------| +| Java (Temurin) | 21 | Building application JARs | +| Maven | 3.9+ | Build system | +| Docker Desktop | latest | Local dev + image building | +| `curl` | any | Script calls to Bunny.net API | +| `python3` | 3.8+ | JSON parsing in setup scripts | + +Install Java 21 via SDKMAN: +```bash +curl -s https://get.sdkman.io | bash +sdk install java 21.0.3-tem +``` + +### Accounts required +| Service | What you need | +|---------|---------------| +| Hetzner Cloud | API token for VPS creation (optional — can provision manually) | +| Bunny.net | Account + API key (`Account → API`) | +| Grafana Cloud | Free tier sufficient; Loki + Prometheus endpoints | +| Domain registrar | Control over DNS for `api-index.org` | + +--- + +## 3. VPS Provisioning (Hetzner) + +### Recommended spec +``` +Type: CPX21 (3 vCPU, 4 GB RAM) — sufficient for MVP +Location: Helsinki (hel1) or Falkenstein (fsn1) +OS: Ubuntu 24.04 LTS +Network: Primary IPv4 + IPv6 dual stack +Backups: Enable automatic backups (adds 20% to monthly cost) +``` + +The CPX21 comfortably runs the full Docker stack (registry + spider + portal + db + +prometheus + grafana + caddy + promtail) under MVP load. Upgrade to CPX31 if Prometheus +retention or portal traffic grows. + +### Firewall rules (Hetzner firewall or `ufw`) + +| Port | Protocol | Source | Purpose | +|------|----------|--------|---------| +| 22 | TCP | Your IP only | SSH | +| 80 | TCP | any | Caddy HTTP→HTTPS redirect | +| 443 | TCP+UDP | any | Caddy HTTPS + HTTP/3 | +| 5514 | TCP | Bunny.net IPs | Promtail syslog receiver | +| 9090 | TCP | VPS localhost | Prometheus (internal only) | +| 3000 | TCP | VPS localhost | Grafana (access via Caddy or SSH tunnel) | + +**Bunny.net syslog source IPs:** Bunny.net does not publish a static IP list; open 5514 +to `0.0.0.0/0` and rely on the Promtail pipeline to discard unexpected traffic. +The syslog format is the only authentication layer needed at this volume. + +### SSH hardening (run as root after first login) +```bash +# Create deploy user +useradd -m -s /bin/bash deploy +usermod -aG sudo,docker deploy + +# Copy your SSH public key +mkdir -p /home/deploy/.ssh +echo "YOUR_PUBLIC_KEY_HERE" > /home/deploy/.ssh/authorized_keys +chown -R deploy:deploy /home/deploy/.ssh +chmod 700 /home/deploy/.ssh +chmod 600 /home/deploy/.ssh/authorized_keys + +# Disable root SSH + password auth +sed -i 's/^PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config +sed -i 's/^PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config +systemctl restart sshd +``` + +--- + +## 4. DNS Configuration + +### Required records + +| Name | Type | Value | TTL | +|------|------|-------|-----| +| `api-index.org` | A | VPS IPv4 | 300 | +| `api-index.org` | AAAA | VPS IPv6 | 300 | +| `www.api-index.org` | CNAME | `api-index.org` | 3600 | + +Set TTL to 300 (5 min) before the cutover so propagation is fast. +After CDN is live (Step 10), change the A/AAAA records to the Bunny.net CNAME instead. + +### After CDN setup (replace A records) +``` +api-index.org CNAME .b-cdn.net +``` + +Caddy still handles TLS on the VPS. Bunny.net terminates the edge TLS and forwards +to the VPS over HTTPS using the Caddy certificate. + +--- + +## 5. Server Bootstrap + +SSH in as `deploy` and run: + +```bash +# 1. System update +sudo apt-get update && sudo apt-get upgrade -y + +# 2. Docker (official repo — apt package is outdated) +curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ + | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg + +echo "deb [arch=$(dpkg --print-architecture) \ + signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] \ + https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" \ + | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +sudo apt-get update +sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin + +# 3. Add deploy user to docker group (logout + login to apply) +sudo usermod -aG docker deploy + +# 4. Install Promtail (for Loki integration — see Step 11) +PROMTAIL_VERSION="3.0.0" +wget -q "https://github.com/grafana/loki/releases/download/v${PROMTAIL_VERSION}/promtail-linux-amd64.zip" +unzip -q promtail-linux-amd64.zip +sudo mv promtail-linux-amd64 /usr/local/bin/promtail +sudo chmod +x /usr/local/bin/promtail +rm promtail-linux-amd64.zip + +# 5. Clone the repository +git clone https://gitea.your-server.example/botstandards/apix-mvp.git /opt/apix +# Or using GitHub mirror during MVP phase: +git clone https://github.com/your-org/apix-mvp.git /opt/apix + +cd /opt/apix +``` + +--- + +## 6. Application Build + +Build JARs locally and copy to the VPS, or build directly on the VPS if Java 21 is installed. + +### Build locally (recommended for CI phase) +```bash +# On your dev machine: +cd bot-service-index/apix-mvp +mvn clean package -DskipTests + +# Copy artifacts to VPS +scp apix-registry/target/quarkus-app/ deploy@:/opt/apix/apix-registry/target/quarkus-app/ -r +scp apix-spider/target/quarkus-app/ deploy@:/opt/apix/apix-spider/target/quarkus-app/ -r +scp apix-portal/target/quarkus-app/ deploy@:/opt/apix/apix-portal/target/quarkus-app/ -r +``` + +### Build on VPS (MVP shortcut) +```bash +# Install Java 21 on VPS +sudo apt-get install -y wget +wget -q "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jdk_x64_linux_hotspot_21.0.3_9.tar.gz" \ + -O /tmp/jdk21.tar.gz +sudo mkdir -p /opt/java +sudo tar xzf /tmp/jdk21.tar.gz -C /opt/java +sudo ln -sf /opt/java/jdk-21.0.3+9/bin/java /usr/local/bin/java +sudo ln -sf /opt/java/jdk-21.0.3+9/bin/javac /usr/local/bin/javac + +# Install Maven +sudo apt-get install -y maven + +# Build +cd /opt/apix +mvn clean package -DskipTests +``` + +### Docker images + +Dockerfiles are defined in WORKLOG Block 5 (I-04 to I-06) and not yet created. +Until they exist, run the JARs directly via `quarkus:dev` or write minimal Dockerfiles: + +```dockerfile +# infra/Dockerfile.registry (placeholder until Block 5) +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY apix-registry/target/quarkus-app/ quarkus-app/ +EXPOSE 8180 +ENTRYPOINT ["java", "-jar", "quarkus-app/quarkus-run.jar"] +``` + +Repeat for `Dockerfile.spider` (port 8082) and `Dockerfile.portal` (port 8081). + +Build and tag: +```bash +cd /opt/apix +docker build -f infra/Dockerfile.registry -t apix-registry:latest . +docker build -f infra/Dockerfile.spider -t apix-spider:latest . +docker build -f infra/Dockerfile.portal -t apix-portal:latest . +``` + +--- + +## 7. Environment Configuration + +Create `/opt/apix/.env` from the template below. This file is in `.gitignore` — never commit it. + +```bash +# /opt/apix/.env — production values + +# ── Database ────────────────────────────────────────────────────────────────── +APIX_DB_USER=apix +APIX_DB_PASSWORD= +APIX_DB_NAME=apix +APIX_DB_PORT=5432 # Only exposed inside Docker network in production + +# ── API security ───────────────────────────────────────────────────────────── +# Used to authenticate write requests (POST /services, PATCH /services/*, etc.) +# Rotate this key when onboarding new registrars. +APIX_API_KEY= + +# ── Registry identity ───────────────────────────────────────────────────────── +APIX_REGISTRY_BASE_URL=https://api-index.org +APIX_REGISTRY_NAME=APIX Registry +APIX_REGISTRY_DESCRIPTION=The open autonomous agent service discovery registry. + +# ── Verification integrations ──────────────────────────────────────────────── +GLEIF_API_URL=https://api.gleif.org/api/v1 +OPENCORPORATES_API_KEY= +APIX_VERIFICATION_TIMEOUT_MS=5000 + +# ── Mail signing (Ed25519) ──────────────────────────────────────────────────── +# Leave blank on first deploy — ephemeral key generated at startup. +# Set before production: openssl genpkey -algorithm ed25519 | ... +APIX_MAIL_SIGNING_PRIVATE_KEY= +APIX_MAIL_SIGNING_PUBLIC_KEY= +APIX_MAIL_SIGNING_KID=2026-05 + +# ── Spider ──────────────────────────────────────────────────────────────────── +SPIDER_INTERVAL_MINUTES=15 + +# ── Grafana ─────────────────────────────────────────────────────────────────── +GRAFANA_ADMIN_PASSWORD= +GRAFANA_ROOT_URL=https://grafana.api-index.org # or http://localhost:3000 if SSH tunnel only + +# ── Logging ─────────────────────────────────────────────────────────────────── +LOG_LEVEL=INFO +``` + +Generate secrets in one pass: +```bash +echo "APIX_DB_PASSWORD=$(openssl rand -base64 32)" +echo "APIX_API_KEY=$(openssl rand -hex 32)" +echo "GRAFANA_ADMIN_PASSWORD=$(openssl rand -base64 16)" +``` + +--- + +## 8. Deploy the Stack + +```bash +cd /opt/apix/infra + +# Start everything +docker compose --env-file ../.env up -d + +# Watch startup logs +docker compose logs -f --tail=50 + +# Verify all services are healthy +docker compose ps +``` + +Expected healthy services after ~60 seconds: + +| Service | Health endpoint | Expected | +|---------|----------------|---------| +| `db` | `pg_isready` | healthy | +| `registry` | `http://localhost:8180/q/health/live` | `{"status":"UP"}` | +| `spider` | `http://localhost:8082/q/health/live` | `{"status":"UP"}` | +| `portal` | `http://localhost:8081/q/health/live` | `{"status":"UP"}` | +| `prometheus` | `http://localhost:9090/-/healthy` | `Prometheus Server is Healthy.` | +| `grafana` | `http://localhost:3000/api/health` | `{"database":"ok"}` | + +Quick smoke test (from VPS): +```bash +# Registry root (HATEOAS navigation) +curl -s http://localhost:8180/ | python3 -m json.tool + +# Metrics endpoint (Prometheus scrape target) +curl -s http://localhost:8180/q/metrics | grep apix_search + +# Search endpoint +curl -s "http://localhost:8180/services?capability=nlp" | python3 -m json.tool +``` + +### Liquibase note + +Liquibase runs automatically at startup (`quarkus.liquibase.migrate-at-start=true`). +If the changelog is missing (`db/changelog/db.changelog-master.xml`), the registry will +fail to start. Check logs with `docker compose logs registry` and ensure migrations +are present (WORKLOG Block 1 / C-20 to C-24). + +--- + +## 9. Caddy TLS Reverse Proxy + +Create `infra/Caddyfile`: + +```caddy +# infra/Caddyfile + +api-index.org { + # Public API — registry + handle /services* { reverse_proxy registry:8180 } + handle /devices* { reverse_proxy registry:8180 } + handle /organizations* { reverse_proxy registry:8180 } + handle /mail-signing-keys { reverse_proxy registry:8180 } + handle / { reverse_proxy registry:8180 } + + # Caddy does not forward /q/* to CDN — Quarkus internals only + handle /q/* { reverse_proxy registry:8180 } + + # Rate limiting (requires caddy-ratelimit plugin or enterprise) + # Basic protection: Caddy's built-in connection limit + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + } + + log { + output file /var/log/caddy/api-index.log + format json + } +} + +# Portal — separate subdomain (optional) +portal.api-index.org { + reverse_proxy portal:8081 + header Strict-Transport-Security "max-age=31536000; includeSubDomains" +} + +# Grafana — restrict to internal access or require basic auth +grafana.api-index.org { + basicauth { + # htpasswd -nb admin — generate and paste hash here + admin $2a$14$REPLACE_WITH_BCRYPT_HASH + } + reverse_proxy grafana:3000 +} +``` + +Caddy fetches TLS certificates from Let's Encrypt automatically on first request. +No manual certificate management needed. + +Rebuild the `caddy` container to pick up the new Caddyfile: +```bash +cd /opt/apix/infra +docker compose restart caddy +docker compose logs caddy -f +``` + +Verify TLS: +```bash +curl -sv https://api-index.org/ 2>&1 | grep -E "SSL|certificate|subject" +``` + +--- + +## 10. Bunny.net CDN Setup + +The CDN sits in front of Caddy/registry and handles ~95% of read traffic from cache. + +### One-time setup +```bash +cd /opt/apix + +# With Loki log forwarding (strongly recommended for observability): +BUNNYNET_API_KEY=your-key \ +ORIGIN_URL=https://api-index.org \ +CUSTOM_HOSTNAME=api-index.org \ +SYSLOG_HOST= \ +SYSLOG_PORT=5514 \ +./scripts/setup-bunnynet.sh +``` + +The script: +1. Creates a pull zone pointing at `https://api-index.org` +2. Enables query-string vary cache (so `?capability=nlp` and `?capability=translation` + are cached as separate entries — critical for correct cache behavior) +3. Sets edge TTL to follow origin `Cache-Control` headers (registry sets `max-age=30` + on `/services` and `/devices`; `max-age=60` on `/`) +4. Adds the `api-index.org` custom hostname +5. Adds an edge rule to bypass cache for `/q/*` (Quarkus health/metrics endpoints) +6. Enables real-time syslog forwarding to Promtail (when `SYSLOG_HOST` is set) +7. Prints the CNAME value for your DNS record + +### Update DNS to point to CDN edge +After the script prints the CDN hostname: +``` +api-index.org CNAME apix-registry.b-cdn.net +``` + +Remove the A/AAAA records pointing directly to the VPS. The VPS is now origin-only. + +### Verify CDN is caching + +```bash +# First request — cache MISS (origin hit) +curl -sI https://api-index.org/services?capability=nlp | grep -i "cache\|x-cache\|age" + +# Second request within 30s — cache HIT +curl -sI https://api-index.org/services?capability=nlp | grep -i "cache\|x-cache\|age" + +# From an Asian machine or VPN endpoint (tests geographic edge) +curl -w "Total: %{time_total}s\n" -o /dev/null -s https://api-index.org/services?capability=nlp +# Target: <20ms after warm-up +``` + +--- + +## 11. Live Telemetry: Promtail → Loki + +Provides real-time telemetry of all CDN traffic (hits + misses) during demos. +Origin Prometheus only sees cache misses — Loki sees everything. + +### Grafana Cloud Loki credentials + +In Grafana Cloud (`grafana.com`): +1. Go to your stack → Loki → Details +2. Note the **Push URL** (e.g. `https://logs-prod-eu-west-0.grafana.net/loki/api/v1/push`) +3. Create a service account with `logs:write` scope → copy the token + +### Configure Promtail + +```bash +# Copy config to system path +sudo cp /opt/apix/scripts/promtail-cdn-logs.yaml /etc/promtail/cdn-logs.yaml + +# Fill in credentials +sudo sed -i 's|https://LOKI_PUSH_URL/loki/api/v1/push|https://logs-prod-eu-west-0.grafana.net/loki/api/v1/push|g' \ + /etc/promtail/cdn-logs.yaml +sudo sed -i 's|"LOKI_USERNAME"|"123456"|g' /etc/promtail/cdn-logs.yaml # stack user ID +sudo sed -i 's|"LOKI_PASSWORD"|"your-token-here"|g' /etc/promtail/cdn-logs.yaml +``` + +Or edit directly: `sudo nano /etc/promtail/cdn-logs.yaml` — replace the three +`LOKI_PUSH_URL`, `LOKI_USERNAME`, `LOKI_PASSWORD` placeholders. + +### Create systemd service + +```bash +sudo tee /etc/systemd/system/promtail.service > /dev/null <<'EOF' +[Unit] +Description=Promtail — Bunny.net CDN log forwarder +After=network.target + +[Service] +User=nobody +Group=nogroup +ExecStart=/usr/local/bin/promtail -config.file=/etc/promtail/cdn-logs.yaml +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target +EOF + +sudo systemctl daemon-reload +sudo systemctl enable --now promtail +sudo systemctl status promtail +``` + +### Verify the pipeline end-to-end + +```bash +# 1. Make a test request through the CDN +curl -s "https://api-index.org/services?capability=nlp" > /dev/null + +# 2. Check Promtail received it (should appear within 1-2 seconds) +sudo journalctl -u promtail -f --no-pager + +# 3. In Grafana Explore (Loki datasource): +# {job="apix-cdn"} — should show a log line from the test request +``` + +--- + +## 12. Grafana Dashboards + +### Add Loki datasource to Grafana + +The stack's provisioned Prometheus datasource is auto-loaded. Add Loki manually or via provisioning: + +```yaml +# infra/grafana/provisioning/datasources/loki.yml +apiVersion: 1 +datasources: + - name: Loki + type: loki + access: proxy + url: https://logs-prod-eu-west-0.grafana.net + basicAuth: true + basicAuthUser: "123456" # Grafana Cloud stack user ID + secureJsonData: + basicAuthPassword: "your-loki-token" + isDefault: false + editable: false +``` + +Restart Grafana to apply: +```bash +docker compose restart grafana +``` + +### Import the OpenClaw demo dashboard + +```bash +# Copy the dashboard JSON to the provisioning directory +cp /opt/apix/scripts/grafana-demo-dashboard.json \ + /opt/apix/infra/grafana/provisioning/dashboards/demo-openclaw.json +``` + +Grafana auto-discovers dashboards in the provisioning path (30s poll interval per `provider.yml`). +No manual import needed. + +Alternatively, import via UI: +1. Grafana → Dashboards → Import +2. Upload `scripts/grafana-demo-dashboard.json` +3. Select the Loki and Prometheus datasources when prompted + +### Dashboard refresh for demo sessions + +The demo dashboard is pre-configured with: +- **Refresh:** 5 seconds +- **Time range:** Last 15 minutes +- **Auto-play:** Enable via Grafana's kiosk mode for the demo screen + +Kiosk mode URL (hides nav bar): +``` +https://grafana.api-index.org/d/apix-demo-openclaw/apix-registry-demo?kiosk&refresh=5s +``` + +--- + +## 13. Weekly Analytics (Bunny.net Logs) + +Bunny.net stores gzip access logs (one file per day). The `query-report.sh` script +downloads them, parses them, and produces a capability frequency report. + +```bash +# Basic report — last 7 days +BUNNYNET_API_KEY=your-key \ +PULL_ZONE_ID=$(cat /opt/apix/.bunnynet-pull-zone-id) \ +./scripts/query-report.sh +``` + +With Prometheus Pushgateway (builds a weekly time-series in Grafana): +```bash +BUNNYNET_API_KEY=your-key \ +PULL_ZONE_ID=$(cat /opt/apix/.bunnynet-pull-zone-id) \ +PROMETHEUS_PUSH_URL=https://pushgateway.your-grafana.example/metrics/job/apix-cdn-report \ +DAYS=7 \ +./scripts/query-report.sh +``` + +### Weekly cron job (on VPS) + +Pass `INSTALL_CRON=true` when running `setup-bunnynet.sh` and the cron entry is +installed automatically — no manual `crontab -e` needed: + +```bash +BUNNYNET_API_KEY=your-key \ +ORIGIN_URL=https://api-index.org \ +INSTALL_CRON=true \ +./scripts/setup-bunnynet.sh +``` + +The script installs a `crontab` entry for the current user (Mondays 06:00) and +deduplicates on re-run — safe to call again after rotating the API key. +Verify with: `crontab -l | grep query-report` + +The report answers: +- Which capabilities are queried most (all requests, including CDN hits) +- Cache hit ratio per endpoint +- Geographic distribution (PoP breakdown) +- Top query string combinations + +--- + +## 14. Verification Checklist + +Run through this after each deployment. + +### Registry +```bash +# HATEOAS root +curl -s https://api-index.org/ | python3 -m json.tool + +# Search (returns empty array if no services registered yet — correct) +curl -s "https://api-index.org/services?capability=nlp" + +# Health +curl -s https://api-index.org/q/health | python3 -m json.tool + +# Cache-Control header on search endpoint +curl -sI "https://api-index.org/services?capability=nlp" | grep -i cache-control +# Expected: Cache-Control: public, max-age=30 +``` + +### CDN +```bash +# Second request should be a cache HIT +curl -sI "https://api-index.org/services?capability=nlp" | grep -i cache +# Expected: X-Cache: HIT (or similar from Bunny.net) + +# Edge latency test — run from a machine outside Germany +curl -w "DNS: %{time_namelookup}s Connect: %{time_connect}s Total: %{time_total}s\n" \ + -o /dev/null -s "https://api-index.org/services?capability=nlp" +# Target: Total <20ms from Asia/US after warm-up +``` + +### Observability +```bash +# Prometheus scraping registry +curl -s http://localhost:9090/api/v1/targets | python3 -c \ + "import sys,json; [print(t['labels']['job'], t['health']) for t in json.load(sys.stdin)['data']['activeTargets']]" + +# Loki receiving CDN logs +# In Grafana Explore: {job="apix-cdn"} | last 5 min should show entries +``` + +### TLS +```bash +curl -sv https://api-index.org/ 2>&1 | grep -E "issuer|subject|expire" +# Should show Let's Encrypt issuer +``` + +--- + +## 15. Routine Operations + +### Update application +```bash +cd /opt/apix + +# Pull latest code +git pull origin main + +# Rebuild affected images +docker build -f infra/Dockerfile.registry -t apix-registry:latest . + +# Rolling restart +docker compose up -d registry +docker compose logs registry -f --tail=50 +``` + +### Rotate API key +```bash +NEW_KEY=$(openssl rand -hex 32) +# Update .env +sed -i "s/^APIX_API_KEY=.*/APIX_API_KEY=${NEW_KEY}/" /opt/apix/.env +# Restart registry to pick up new key +docker compose up -d registry +echo "New key: ${NEW_KEY}" +# Distribute to all registrar clients before the old key expires +``` + +### Database backup +```bash +# Manual backup +docker exec apix-infra-db-1 pg_dump -U apix apix | gzip > /opt/apix/backups/apix-$(date +%Y%m%d).sql.gz + +# Automated daily backup via cron +0 2 * * * docker exec apix-infra-db-1 pg_dump -U apix apix | gzip \ + > /opt/apix/backups/apix-$(date +\%Y\%m\%d).sql.gz + +# Restore +gunzip -c /opt/apix/backups/apix-20260101.sql.gz | docker exec -i apix-infra-db-1 psql -U apix apix +``` + +### View logs +```bash +# Live registry logs +docker compose -f /opt/apix/infra/docker-compose.yml logs registry -f + +# Use the convenience scripts (from project root) +./scripts/logs.sh registry +./scripts/logs.sh spider +./scripts/logs.sh portal + +# Promtail (CDN log forwarder) +sudo journalctl -u promtail -f +``` + +### Purge CDN cache (after deploying schema changes) +```bash +curl -sf -X POST "https://api.bunny.net/pullzone/$(cat /opt/apix/.bunnynet-pull-zone-id)/purgeCache" \ + -H "AccessKey: ${BUNNYNET_API_KEY}" +``` + +### Stop / restart the full stack +```bash +cd /opt/apix/infra +./scripts/stop.sh # graceful stop +./scripts/restart.sh # stop + start +./scripts/reset.sh # WARNING: drops all volumes including DB data +``` + +--- + +## Appendix: Environment Variable Reference + +All variables accepted by the `registry` container, sourced from `.env`: + +| Variable | Default | Required | Description | +|----------|---------|----------|-------------| +| `QUARKUS_DATASOURCE_JDBC_URL` | `jdbc:postgresql://db:5432/apix` | yes | Database JDBC URL | +| `QUARKUS_DATASOURCE_USERNAME` | `apix` | yes | DB username | +| `QUARKUS_DATASOURCE_PASSWORD` | `apix` | yes | DB password — use a strong value | +| `APIX_API_KEY` | `dev-insecure-key-change-in-prod` | yes | Write-endpoint auth key | +| `APIX_REGISTRY_BASE_URL` | `http://localhost:8180` | yes | Used in HATEOAS links | +| `GLEIF_API_URL` | `https://api.gleif.org/api/v1` | no | O2 verification: GLEIF REST API | +| `OPENCORPORATES_API_KEY` | _(blank)_ | no | O2 verification: OpenCorporates | +| `APIX_VERIFICATION_TIMEOUT_MS` | `5000` | no | HTTP timeout for verification calls | +| `APIX_MAIL_SIGNING_PRIVATE_KEY` | _(blank)_ | no | Ed25519 private key, Base64; ephemeral if blank | +| `APIX_MAIL_SIGNING_PUBLIC_KEY` | _(blank)_ | no | Ed25519 public key, Base64 | +| `APIX_MAIL_SIGNING_KID` | `dev` | no | Key ID in signed payloads; rotate every 6 months | +| `SANCTIONS_CACHE_PATH` | `./sanctions-cache` | no | Local path for sanctions list cache | +| `LOG_LEVEL` | `INFO` | no | `DEBUG` / `INFO` / `WARNING` / `ERROR` |