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
@@ -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)
@@ -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")
@@ -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");
}
}
@@ -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<String, String> 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<String, Object> 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<String, Object> 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<String, Object> extensions = colon > 0
? Map.of(extension.substring(0, colon), extension.substring(colon + 1))
: Map.of();
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String> 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<String> 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<String, Object> 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<String> 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<Map<String, Object>> 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<String, Integer> scores, String model, String provider) {
submitFeedbackResponse(sandboxName, scores, model, provider);
}
private Response submitFeedbackResponse(String sandboxName, Map<String, Integer> scores, String model, String provider) {
Map<String, Object> 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<String, Object> buildServicePayload(String name, String endpoint, String capability) {
Map<String, Object> 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;
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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<String, UUID> serviceIds = new HashMap<>();
private Response lastResponse;
// ── Helpers ───────────────────────────────────────────────────────────────
private RequestSpecification asOwner() {
return given().contentType(JSON).header(API_KEY_HEADER, API_KEY);
}
private Map<String, Object> basePayload(String name) {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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));
}
}
@@ -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"
@@ -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"
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
@@ -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>
@@ -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);
}
}