chore: add missing source modules to version control
Deploy to Production / deploy (push) Failing after 7s

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 <noreply@anthropic.com>
This commit is contained in:
Carsten Rehfeld
2026-05-14 15:49:03 +02:00
parent a9b3354bde
commit 46f32c2df2
87 changed files with 6657 additions and 34 deletions
@@ -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=<term>` — 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 {}
@@ -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); }
}
}
}
@@ -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<DimensionScore> scores,
Map<String, Integer> submissionsByProvider,
@JsonProperty("_links") SandboxLinks links
) {
public record DimensionScore(
String key,
String question,
double average,
int votes
) {}
}
@@ -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
) {}
@@ -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<FeedbackDimension> dimensions,
@JsonProperty("_links") FeedbackSchemaLinks links
) {
public record Scale(int min, int max, String minLabel, String maxLabel) {}
public record FeedbackSchemaLinks(SandboxLinks.LinkRef self) {}
}
@@ -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<String, Integer> 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
) {}
@@ -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); }
}
}
}
@@ -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
) {}
@@ -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
) {}
@@ -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); }
}
}
@@ -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
) {}
@@ -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
) {}
@@ -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<String, Long> usage,
/** Timestamp of the most recent tracked request across all event types. */
Instant lastActivityAt,
@JsonProperty("_links") SandboxLinks links
) {}
@@ -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<UUID> replacesServiceIds,
Instant registeredAt,
Instant lastUpdatedAt,
IotProfileResponse iotProfile
IotProfileResponse iotProfile,
@JsonInclude(JsonInclude.Include.NON_EMPTY)
Map<String, Object> 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()
);
}
}
@@ -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;
}
@@ -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")
@@ -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);
}
}
@@ -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();
}
}
@@ -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<String, String> raw) {
Map<String, String> 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<String, String> raw) {
Map<String, String> 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<String, String> raw) {
Map<String, String> 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<String, String> map, String key, String value) {
if (value != null) map.put(key, value);
}
private void putIfNotDefault(Map<String, String> map, String key, String value, String defaultValue) {
if (value != null && !value.equals(defaultValue)) map.put(key, value);
}
private String render(Map<String, String> params) {
return params.entrySet().stream()
.map(e -> e.getKey() + "=" + e.getValue())
.collect(joining("&"));
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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<ServiceResponse> listServices(
@PathParam("uuid") String uuidStr,
@QueryParam("capability") String capability,
@QueryParam("stage") String stage,
@QueryParam("property") List<String> 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 (010) 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<String, String> 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());
}
}
}
@@ -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<ServiceResponse> 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<ServiceResponse> 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<String> 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<VersionHistoryEntry> 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(
@@ -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<double[]> 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<String> 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<double[]> 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<String> 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;
}
}
@@ -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;
}
}
@@ -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<ServiceEntity> search(String capability, String stage) {
public List<ServiceEntity> search(String capability, String stage, List<String> properties) {
ServiceStage targetStage = stage != null
? ServiceStage.valueOf(stage.toUpperCase())
: ServiceStage.PRODUCTION;
return em.createNativeQuery(
List<String[]> 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<String[]> parsePropertyFilters(List<String> properties) {
if (properties == null || properties.isEmpty()) return List.of();
List<String[]> 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<ServiceEntity> 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<ServiceEntity> stream = ((List<ServiceEntity>) 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<VersionHistoryEntry> 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();
@@ -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<SandboxEntity> 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<ServiceResponse> listServices(String sandboxId) {
return ((List<ServiceEntity>) 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<ServiceResponse> searchServices(String sandboxId, String capability, String stage,
List<String> properties) {
ServiceStage targetStage = stage != null
? ServiceStage.valueOf(stage.toUpperCase())
: ServiceStage.DEVELOPMENT;
List<String[]> 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<ServiceEntity>) 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<Object[]> 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<String, Long> 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<Object[]> 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<String, Long> 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<Object[]> 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<SandboxDashboardResponse.AgentVisit> 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<String, Integer> 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 010"))
.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<Object[]> 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<FeedbackAggregateResponse.DimensionScore> 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<Object[]> 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<String, Integer> 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<FeedbackDimension> 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) {}
}
@@ -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
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="012" author="apix" dbms="postgresql" runInTransaction="false">
<!-- GIN index on the capabilities array inside the bsm_payload JSONB column.
GET /services?capability=X uses a JSONB containment query (@>) which
requires a GIN index to avoid a full table scan at any practical
registry size. Without this index every capability search is O(n). -->
<sql>
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_services_capabilities
ON services USING GIN ((bsm_payload -> 'capabilities'));
</sql>
<rollback>
DROP INDEX CONCURRENTLY IF EXISTS idx_services_capabilities;
</rollback>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="013" author="apix">
<!-- Sandbox registry: isolated test namespaces, key-scoped, tier-limited -->
<createTable tableName="sandboxes">
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<!-- URL-safe slug chosen by registrant, e.g. "openclaw" -->
<column name="name" type="varchar(100)">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_sandbox_name"/>
</column>
<column name="contact_email" type="varchar(255)">
<constraints nullable="false"/>
</column>
<!-- SHA-256 hex hash of the plaintext key — never stored in plaintext -->
<column name="api_key_hash" type="varchar(64)">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_sandbox_api_key_hash"/>
</column>
<!-- FREE | STANDARD | PROFESSIONAL | FOUNDER -->
<column name="tier" type="varchar(50)" defaultValue="FREE">
<constraints nullable="false"/>
</column>
<!-- Cached from tier at creation time; surfaced in response so clients know their ceiling -->
<column name="rate_per_minute" type="integer" defaultValueNumeric="60">
<constraints nullable="false"/>
</column>
<column name="created_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
<!-- FREE = created_at + 30 days; extended by tier upgrade -->
<column name="expires_at" type="timestamptz">
<constraints nullable="false"/>
</column>
</createTable>
<!-- Scope services to a sandbox: NULL = production, set = sandbox namespace -->
<addColumn tableName="services">
<column name="sandbox_id" type="varchar(100)"/>
</addColumn>
<!-- Drop the global endpoint_url unique constraint — sandbox rows share URLs freely -->
<dropUniqueConstraint tableName="services" constraintName="uq_services_endpoint_url"/>
<!-- Production rows: endpoint_url unique among production only -->
<sql>
CREATE UNIQUE INDEX uq_services_endpoint_production
ON services(endpoint_url)
WHERE sandbox_id IS NULL;
</sql>
<!-- Index for sandbox queries: list all services in a given sandbox -->
<sql>
CREATE INDEX idx_services_sandbox_id
ON services(sandbox_id)
WHERE sandbox_id IS NOT NULL;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="014" author="apix">
<!-- Per-sandbox request counters — upserted on every tracked endpoint call.
Intentionally separate from production Micrometer metrics: sandbox activity
must never inflate production telemetry. -->
<createTable tableName="sandbox_usage_stats">
<column name="sandbox_id" type="varchar(100)">
<constraints nullable="false"/>
</column>
<!-- SERVICE_REGISTERED | SERVICE_LISTED | SERVICE_SEARCHED | SANDBOX_VIEWED -->
<column name="event_type" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="request_count" type="bigint" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="last_requested_at" type="timestamptz"/>
</createTable>
<addPrimaryKey tableName="sandbox_usage_stats"
columnNames="sandbox_id, event_type"
constraintName="pk_sandbox_usage_stats"/>
<addForeignKeyConstraint
baseTableName="sandbox_usage_stats"
baseColumnNames="sandbox_id"
referencedTableName="sandboxes"
referencedColumnNames="name"
constraintName="fk_sandbox_usage_sandbox_name"
onDelete="CASCADE"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="015" author="apix">
<!-- Registration caps per sandbox. NULL = unlimited (COMMUNITY / FOUNDER tiers).
Stored explicitly so BSF can override per partner without changing tier. -->
<addColumn tableName="sandboxes">
<column name="max_services" type="integer">
<constraints nullable="true"/>
</column>
</addColumn>
<addColumn tableName="sandboxes">
<column name="max_orgs" type="integer">
<constraints nullable="true"/>
</column>
</addColumn>
<!-- Back-fill existing FREE sandboxes -->
<sql>UPDATE sandboxes SET max_services = 10, max_orgs = 3 WHERE tier = 'FREE';</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="016" author="apix">
<!-- Agent experience feedback — scores stored as JSONB so new dimensions
require no schema migration. One row per submission. -->
<createTable tableName="sandbox_feedback">
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="sandbox_id" type="varchar(100)">
<constraints nullable="false"
foreignKeyName="fk_sandbox_feedback_sandbox"
references="sandboxes(name)"
deleteCascade="true"/>
</column>
<!-- { "hateoas_navigation": 8, "discovery_accuracy": 7, ... } -->
<column name="scores" type="jsonb">
<constraints nullable="false"/>
</column>
<!-- Optional: agent may self-identify (tool name, version string, etc.) -->
<column name="agent_identifier" type="varchar(255)"/>
<column name="comment" type="varchar(500)"/>
<column name="submitted_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="sandbox_feedback" indexName="idx_sandbox_feedback_sandbox_id">
<column name="sandbox_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="017" author="apix">
<!-- Model identity on feedback submissions — enables cross-model score analysis.
Both nullable: agents may not know or may choose not to disclose. -->
<addColumn tableName="sandbox_feedback">
<!-- Full model identifier as the agent knows it: "claude-sonnet-4-6", "gpt-4o-2024-11-20" -->
<column name="model_identifier" type="varchar(255)"/>
</addColumn>
<addColumn tableName="sandbox_feedback">
<!-- Provider family: "anthropic", "openai", "google", "meta", "mistral" … -->
<column name="model_provider" type="varchar(100)"/>
</addColumn>
<!-- Index for provider-grouped aggregate queries -->
<createIndex tableName="sandbox_feedback" indexName="idx_sandbox_feedback_model_provider">
<column name="model_provider"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="018" author="apix">
<!-- Optional registrar location: owner-declared free-text (e.g. "Berlin, Germany").
Raw IP is never stored — resolved to coordinates once at registration time.
geo_consent_given records that the owner explicitly chose to share their location;
required for GDPR Art. 7 accountability (demonstrating consent was given). -->
<addColumn tableName="sandboxes">
<column name="registrar_location" type="varchar(255)"/>
</addColumn>
<addColumn tableName="sandboxes">
<column name="registrar_lat" type="double precision"/>
</addColumn>
<addColumn tableName="sandboxes">
<column name="registrar_lon" type="double precision"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="019" author="apix">
<!-- Agent visit geo-points for the sandbox world map.
Raw IP is NEVER stored — resolved to lat/lon at request time and discarded.
Storing only city-level coordinates is GDPR-compliant under legitimate interest
(Art. 6(1)(f)): aggregate, non-identifying usage analytics for the sandbox owner. -->
<createTable tableName="sandbox_agent_visits">
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="sandbox_id" type="varchar(100)">
<constraints nullable="false"/>
</column>
<column name="agent_lat" type="double precision">
<constraints nullable="false"/>
</column>
<column name="agent_lon" type="double precision">
<constraints nullable="false"/>
</column>
<column name="visited_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint
baseTableName="sandbox_agent_visits"
baseColumnNames="sandbox_id"
referencedTableName="sandboxes"
referencedColumnNames="name"
constraintName="fk_agent_visits_sandbox_name"
onDelete="CASCADE"/>
<createIndex tableName="sandbox_agent_visits" indexName="idx_agent_visits_sandbox_time">
<column name="sandbox_id"/>
<column name="visited_at" descending="true"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="020" author="apix">
<!-- Sandbox name is now a display label only — not a resource identifier.
UUID is the sole lookup key. All FKs and the unique constraint that tied
sandbox_id = name are dropped; the varchar(100) columns remain and now
store UUID strings. Existing sandbox data is purged because old name-based
API keys and routes are incompatible with the new UUID-only routes. -->
<sql>TRUNCATE TABLE sandbox_agent_visits, sandbox_usage_stats, sandbox_feedback, sandboxes CASCADE;</sql>
<!-- Drop FKs before unique constraint — they reference the index backing uq_sandbox_name -->
<dropForeignKeyConstraint baseTableName="sandbox_usage_stats" constraintName="fk_sandbox_usage_sandbox_name"/>
<dropForeignKeyConstraint baseTableName="sandbox_agent_visits" constraintName="fk_agent_visits_sandbox_name"/>
<dropForeignKeyConstraint baseTableName="sandbox_feedback" constraintName="fk_sandbox_feedback_sandbox"/>
<!-- Drop name uniqueness — name is a label, may repeat -->
<dropUniqueConstraint tableName="sandboxes" constraintName="uq_sandbox_name"/>
<!-- services.sandbox_id had no FK to sandboxes — nothing to drop there. -->
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="021" author="apix">
<!-- Second key returned at registration, shown once only.
Used exclusively for administrative operations:
- PATCH /sandbox/{uuid}/extend (renew expiry)
- PATCH /sandbox/{uuid}/api-key (rotate service key)
Separation of concerns: agents embed the apiKey; the owner keeps the
maintenanceKey for lifecycle control without exposing it to agents. -->
<addColumn tableName="sandboxes">
<column name="maintenance_key_hash" type="varchar(64)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
</addColumn>
<!-- Back-fill placeholder removed immediately after adding the column so no
real row keeps the literal "PENDING" value. Existing sandboxes were
purged in changeset 020; this table is empty at this point. -->
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="022" author="apix">
<!-- Open-beta / training mode: registrants who are bots or classrooms
do not always have a meaningful contact email. Email is still
accepted and stored when provided; omitting it is valid. -->
<dropNotNullConstraint tableName="sandboxes" columnName="contact_email"/>
</changeSet>
</databaseChangeLog>
@@ -16,5 +16,16 @@
<include file="changes/009-iot-profiles.xml" relativeToChangelogFile="true"/>
<include file="changes/010-organizations.xml" relativeToChangelogFile="true"/>
<include file="changes/011-org-rotation-challenge.xml" relativeToChangelogFile="true"/>
<include file="changes/012-capabilities-gin-index.xml" relativeToChangelogFile="true"/>
<include file="changes/013-sandbox.xml" relativeToChangelogFile="true"/>
<include file="changes/014-sandbox-usage.xml" relativeToChangelogFile="true"/>
<include file="changes/015-sandbox-caps.xml" relativeToChangelogFile="true"/>
<include file="changes/016-sandbox-feedback.xml" relativeToChangelogFile="true"/>
<include file="changes/017-feedback-model-info.xml" relativeToChangelogFile="true"/>
<include file="changes/018-sandbox-geo.xml" relativeToChangelogFile="true"/>
<include file="changes/019-sandbox-agent-visits.xml" relativeToChangelogFile="true"/>
<include file="changes/020-sandbox-uuid-routing.xml" relativeToChangelogFile="true"/>
<include file="changes/021-sandbox-maintenance-key.xml" relativeToChangelogFile="true"/>
<include file="changes/022-optional-email.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>