diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/CredentialFormat.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/CredentialFormat.java new file mode 100644 index 0000000..c8fd8dd --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/CredentialFormat.java @@ -0,0 +1,8 @@ +package org.botstandards.apix.registry.dto; + +public enum CredentialFormat { + JWT, + PSK, + X509, + NONE +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/IotProfileRequest.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/IotProfileRequest.java new file mode 100644 index 0000000..1759188 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/IotProfileRequest.java @@ -0,0 +1,19 @@ +package org.botstandards.apix.registry.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record IotProfileRequest( + String hubUrl, + List protocols, + String tlsMinVersion, + String provisioningEndpoint, + CredentialFormat credentialFormat, + String deviceIdField, + List deviceClasses, + String minFirmwareVersion, + String firmwareUpdateUrl, + Integer estimatedMigrationMinutes +) {} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/IotProfileResponse.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/IotProfileResponse.java new file mode 100644 index 0000000..864efcc --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/IotProfileResponse.java @@ -0,0 +1,36 @@ +package org.botstandards.apix.registry.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import org.botstandards.apix.registry.entity.IotProfileEntity; + +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record IotProfileResponse( + String hubUrl, + List protocols, + String tlsMinVersion, + String provisioningEndpoint, + String credentialFormat, + String deviceIdField, + List deviceClasses, + String minFirmwareVersion, + String firmwareUpdateUrl, + Integer estimatedMigrationMinutes +) { + public static IotProfileResponse from(IotProfileEntity e) { + if (e == null) return null; + return new IotProfileResponse( + e.hubUrl, + e.protocols, + e.tlsMinVersion, + e.provisioningEndpoint, + e.credentialFormat != null ? e.credentialFormat.name() : null, + e.deviceIdField, + e.deviceClasses, + e.minFirmwareVersion, + e.firmwareUpdateUrl, + e.estimatedMigrationMinutes + ); + } +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/IotProtocol.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/IotProtocol.java new file mode 100644 index 0000000..ac7386d --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/IotProtocol.java @@ -0,0 +1,10 @@ +package org.botstandards.apix.registry.dto; + +public enum IotProtocol { + MQTT_3_1_1, + MQTT_5_0, + WEBSOCKET, + COAP, + HTTP_1_1, + AMQP +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/ReplacementsResponse.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/ReplacementsResponse.java index 0fd400f..0b4f11b 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/ReplacementsResponse.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/ReplacementsResponse.java @@ -3,6 +3,7 @@ package org.botstandards.apix.registry.dto; import com.fasterxml.jackson.annotation.JsonInclude; import org.botstandards.apix.common.OLevel; import org.botstandards.apix.common.ServiceStage; +import org.botstandards.apix.registry.dto.IotProfileResponse; import java.time.Instant; import java.util.List; @@ -20,6 +21,7 @@ public record ReplacementsResponse( String name, String endpoint, OLevel oLevel, - ServiceStage serviceStage + ServiceStage serviceStage, + IotProfileResponse iotProfile ) {} } diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/ServicePatchRequest.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/ServicePatchRequest.java index 37d88a2..b9ebabb 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/ServicePatchRequest.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/ServicePatchRequest.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import org.botstandards.apix.common.BsmPayload; import org.botstandards.apix.common.OrgType; import org.botstandards.apix.common.ServiceStage; +import org.botstandards.apix.registry.dto.IotProfileRequest; import java.time.Instant; import java.util.List; @@ -30,5 +31,6 @@ public record ServicePatchRequest( Boolean locked, Instant sunsetAt, String migrationGuideUrl, - List replacesServiceIds + List replacesServiceIds, + IotProfileRequest iotProfile ) {} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/ServiceResponse.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/ServiceResponse.java index e82ec78..a19ab14 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/ServiceResponse.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/ServiceResponse.java @@ -3,6 +3,7 @@ package org.botstandards.apix.registry.dto; import com.fasterxml.jackson.annotation.JsonInclude; import org.botstandards.apix.common.*; import org.botstandards.apix.registry.entity.ServiceEntity; +import org.botstandards.apix.registry.dto.IotProfileResponse; import java.time.Instant; import java.util.List; @@ -35,7 +36,8 @@ public record ServiceResponse( String migrationGuideUrl, List replacesServiceIds, Instant registeredAt, - Instant lastUpdatedAt + Instant lastUpdatedAt, + IotProfileResponse iotProfile ) { public static ServiceResponse from(ServiceEntity e) { BsmPayload b = e.bsmPayload; @@ -65,7 +67,8 @@ public record ServiceResponse( e.migrationGuideUrl, b.replacesServiceIds(), e.registeredAt, - e.lastUpdatedAt + e.lastUpdatedAt, + IotProfileResponse.from(e.iotProfile) ); } } diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/entity/IotProfileEntity.java b/apix-registry/src/main/java/org/botstandards/apix/registry/entity/IotProfileEntity.java new file mode 100644 index 0000000..406e036 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/entity/IotProfileEntity.java @@ -0,0 +1,64 @@ +package org.botstandards.apix.registry.entity; + +import jakarta.persistence.*; +import org.botstandards.apix.registry.dto.CredentialFormat; +import org.botstandards.apix.registry.persistence.JsonStringListConverter; +import org.hibernate.annotations.ColumnTransformer; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "iot_profiles") +public class IotProfileEntity { + + @Id + @Column(columnDefinition = "uuid") + public UUID id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "service_id", nullable = false, unique = true) + public ServiceEntity service; + + @Column(name = "hub_url", nullable = false, length = 512) + public String hubUrl; + + @Convert(converter = JsonStringListConverter.class) + @Column(name = "protocols", columnDefinition = "jsonb", nullable = false) + @ColumnTransformer(write = "?::jsonb") + public List protocols; + + @Column(name = "tls_min_version", length = 10, nullable = false) + public String tlsMinVersion = "1.2"; + + @Column(name = "provisioning_endpoint", length = 512) + public String provisioningEndpoint; + + @Enumerated(EnumType.STRING) + @Column(name = "credential_format", length = 30) + public CredentialFormat credentialFormat; + + @Column(name = "device_id_field", length = 100) + public String deviceIdField; + + @Convert(converter = JsonStringListConverter.class) + @Column(name = "device_classes", columnDefinition = "jsonb", nullable = false) + @ColumnTransformer(write = "?::jsonb") + public List deviceClasses; + + @Column(name = "min_firmware_version", length = 50) + public String minFirmwareVersion; + + @Column(name = "firmware_update_url", length = 512) + public String firmwareUpdateUrl; + + @Column(name = "estimated_migration_minutes") + public Integer estimatedMigrationMinutes; + + @Column(name = "created_at", nullable = false) + public Instant createdAt; + + @Column(name = "updated_at", nullable = false) + public Instant updatedAt; +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/entity/ServiceEntity.java b/apix-registry/src/main/java/org/botstandards/apix/registry/entity/ServiceEntity.java index 9e698c9..e680461 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/entity/ServiceEntity.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/entity/ServiceEntity.java @@ -4,6 +4,8 @@ import jakarta.persistence.*; import org.botstandards.apix.common.*; import org.botstandards.apix.registry.persistence.BsmPayloadConverter; import org.hibernate.annotations.ColumnTransformer; +import org.hibernate.annotations.Fetch; +import org.hibernate.annotations.FetchMode; import java.time.Instant; import java.util.UUID; @@ -61,4 +63,11 @@ public class ServiceEntity { @Column(name = "migration_guide_url") public String migrationGuideUrl; + + // EAGER so the profile is available without an explicit @Transactional on read paths. + // iot_profiles rows are sparse — Hibernate uses a LEFT JOIN for em.find()/JPQL, + // and a secondary SELECT per entity for native queries. Both are acceptable at registry scale. + @OneToOne(mappedBy = "service", fetch = FetchType.EAGER, optional = true) + @Fetch(FetchMode.SELECT) + public IotProfileEntity iotProfile; } diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/persistence/JsonStringListConverter.java b/apix-registry/src/main/java/org/botstandards/apix/registry/persistence/JsonStringListConverter.java new file mode 100644 index 0000000..8eb0b4d --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/persistence/JsonStringListConverter.java @@ -0,0 +1,34 @@ +package org.botstandards.apix.registry.persistence; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.json.JsonMapper; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import java.util.List; + +@Converter +public class JsonStringListConverter implements AttributeConverter, String> { + + private static final JsonMapper MAPPER = JsonMapper.builder().build(); + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null) return null; + try { + return MAPPER.writeValueAsString(attribute); + } catch (Exception e) { + throw new IllegalStateException("Cannot serialize string list to JSON", e); + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null) return null; + try { + return MAPPER.readValue(dbData, new TypeReference<>() {}); + } catch (Exception e) { + throw new IllegalStateException("Cannot deserialize JSON to string list", e); + } + } +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/resource/ServiceResource.java b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/ServiceResource.java index 4fa665f..d8eacb9 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/resource/ServiceResource.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/ServiceResource.java @@ -77,8 +77,11 @@ public class ServiceResource { @GET @Path("/{id}/replacements") public Response getReplacements(@PathParam("id") UUID id, - @QueryParam("minOLevel") String minOLevel) { - ReplacementsResponse body = registryService.getReplacements(id, minOLevel); + @QueryParam("minOLevel") String minOLevel, + @QueryParam("iotReady") Boolean iotReady, + @QueryParam("deviceClass") String deviceClass, + @QueryParam("protocol") String protocol) { + ReplacementsResponse body = registryService.getReplacements(id, minOLevel, iotReady, deviceClass, protocol); return Response.ok(body) .header("Cache-Control", "public, max-age=60") .build(); diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/service/RegistryService.java b/apix-registry/src/main/java/org/botstandards/apix/registry/service/RegistryService.java index 8cf2278..daa7b61 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/service/RegistryService.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/service/RegistryService.java @@ -11,12 +11,17 @@ import org.botstandards.apix.common.*; import org.botstandards.apix.registry.dto.ReplacementsResponse; import org.botstandards.apix.registry.dto.ServicePatchRequest; import org.botstandards.apix.registry.dto.VersionHistoryEntry; +import org.botstandards.apix.registry.dto.IotProfileRequest; +import org.botstandards.apix.registry.dto.IotProfileResponse; +import org.botstandards.apix.registry.dto.IotProtocol; +import org.botstandards.apix.registry.entity.IotProfileEntity; import org.botstandards.apix.registry.entity.ServiceEntity; import org.botstandards.apix.registry.entity.ServiceReplacementEntity; import org.botstandards.apix.registry.entity.ServiceVersionEntity; import java.time.Instant; import java.util.*; +import java.util.stream.Stream; @ApplicationScoped public class RegistryService { @@ -113,6 +118,9 @@ public class RegistryService { if (req.replacesServiceIds() != null) { syncReplacements(service.id, req.replacesServiceIds(), service.lastUpdatedAt); } + if (req.iotProfile() != null) { + upsertIotProfile(service, req.iotProfile(), service.lastUpdatedAt); + } return service; } @@ -135,7 +143,8 @@ public class RegistryService { } @SuppressWarnings("unchecked") - public ReplacementsResponse getReplacements(UUID deprecatedId, String minOLevelStr) { + public ReplacementsResponse getReplacements(UUID deprecatedId, String minOLevelStr, + Boolean iotReady, String deviceClass, String protocol) { ServiceEntity deprecated = requireById(deprecatedId); // Locked services that are not yet DECOMMISSIONED block replacement discovery @@ -153,14 +162,30 @@ public class RegistryService { OLevel minOLevel = minOLevelStr != null ? OLevel.valueOf(minOLevelStr) : null; + Stream stream = candidates.stream() + .filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal()); + if (Boolean.TRUE.equals(iotReady)) { + stream = stream.filter(c -> c.iotProfile != null); + } + if (deviceClass != null && !deviceClass.isBlank()) { + stream = stream.filter(c -> c.iotProfile != null + && c.iotProfile.deviceClasses != null + && c.iotProfile.deviceClasses.contains(deviceClass)); + } + if (protocol != null && !protocol.isBlank()) { + stream = stream.filter(c -> c.iotProfile != null + && c.iotProfile.protocols != null + && c.iotProfile.protocols.contains(protocol)); + } + return new ReplacementsResponse( deprecated.locked, deprecated.sunsetAt, - candidates.stream() - .filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal()) + stream .sorted(Comparator.comparingInt((ServiceEntity c) -> c.olevel.ordinal()).reversed()) .map(c -> new ReplacementsResponse.Candidate( - c.id, c.bsmPayload.name(), c.endpointUrl, c.olevel, c.serviceStage)) + c.id, c.bsmPayload.name(), c.endpointUrl, c.olevel, c.serviceStage, + IotProfileResponse.from(c.iotProfile))) .toList() ); } @@ -290,6 +315,35 @@ public class RegistryService { } } + private void upsertIotProfile(ServiceEntity service, IotProfileRequest req, Instant now) { + IotProfileEntity p = service.iotProfile; + boolean isNew = (p == null); + if (isNew) { + if (req.hubUrl() == null) throw unprocessable("iotProfile.hubUrl is required"); + if (req.protocols() == null || req.protocols().isEmpty()) throw unprocessable("iotProfile.protocols is required"); + if (req.deviceClasses() == null || req.deviceClasses().isEmpty()) throw unprocessable("iotProfile.deviceClasses is required"); + p = new IotProfileEntity(); + p.id = UUID.randomUUID(); + p.service = service; + p.createdAt = now; + } + if (req.hubUrl() != null) p.hubUrl = req.hubUrl(); + if (req.protocols() != null) p.protocols = req.protocols().stream().map(IotProtocol::name).toList(); + if (req.tlsMinVersion() != null) p.tlsMinVersion = req.tlsMinVersion(); + if (req.provisioningEndpoint() != null) p.provisioningEndpoint = req.provisioningEndpoint(); + if (req.credentialFormat() != null) p.credentialFormat = req.credentialFormat(); + if (req.deviceIdField() != null) p.deviceIdField = req.deviceIdField(); + if (req.deviceClasses() != null) p.deviceClasses = req.deviceClasses(); + if (req.minFirmwareVersion() != null) p.minFirmwareVersion = req.minFirmwareVersion(); + if (req.firmwareUpdateUrl() != null) p.firmwareUpdateUrl = req.firmwareUpdateUrl(); + if (req.estimatedMigrationMinutes() != null) p.estimatedMigrationMinutes = req.estimatedMigrationMinutes(); + p.updatedAt = now; + if (isNew) { + service.iotProfile = p; + em.persist(p); + } + } + private void upsertReplacement(UUID deprecatedId, UUID replacementId, Instant now) { long count = ((Number) em.createNativeQuery( "SELECT COUNT(*) FROM service_replacements " + diff --git a/apix-registry/src/main/resources/db/changelog/changes/009-iot-profiles.xml b/apix-registry/src/main/resources/db/changelog/changes/009-iot-profiles.xml new file mode 100644 index 0000000..c89ad91 --- /dev/null +++ b/apix-registry/src/main/resources/db/changelog/changes/009-iot-profiles.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CREATE INDEX idx_iot_device_classes ON iot_profiles USING gin(device_classes) + CREATE INDEX idx_iot_protocols ON iot_profiles USING gin(protocols) + + + diff --git a/apix-registry/src/main/resources/db/changelog/db.changelog-master.xml b/apix-registry/src/main/resources/db/changelog/db.changelog-master.xml index 4834ab3..d43cc32 100644 --- a/apix-registry/src/main/resources/db/changelog/db.changelog-master.xml +++ b/apix-registry/src/main/resources/db/changelog/db.changelog-master.xml @@ -13,5 +13,6 @@ + diff --git a/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotProfileCucumberTest.java b/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotProfileCucumberTest.java new file mode 100644 index 0000000..8b482ca --- /dev/null +++ b/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotProfileCucumberTest.java @@ -0,0 +1,27 @@ +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; + +/** + * Runs BDD scenarios for the IoT device connection profile feature. + * Shares step definitions with IotTransitionCucumberTest via the same glue package. + */ +@QuarkusTest +public class IotProfileCucumberTest { + + @Test + public void run() { + byte exitCode = Main.run( + "--glue", "org.botstandards.apix.registry.bdd", + "--plugin", "pretty", + "--plugin", "json:target/cucumber-report-iot-profile.json", + "--plugin", "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm", + "classpath:features/iot-profile" + ); + assertEquals(0, exitCode, "One or more IoT profile Cucumber scenarios failed"); + } +} diff --git a/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java b/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java index 6a46542..5273ed2 100644 --- a/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java +++ b/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java @@ -783,4 +783,93 @@ public class IotTransitionSteps { .doesNotContain("user-agent"); } } + + // ── IoT Profile — Given (setup) ────────────────────────────────────────── + + @Given("{string} has IoT profile hubUrl {string} protocols {string} deviceClasses {string}") + public void hasIotProfile(String name, String hubUrl, String protocols, String deviceClasses) { + asTemplateOwner() + .body(Map.of("iotProfile", Map.of( + "hubUrl", hubUrl, + "protocols", List.of(protocols), + "deviceClasses", List.of(deviceClasses) + ))) + .patch("/services/" + serviceIds.get(name)) + .then().statusCode(200); + } + + // ── IoT Profile — When ─────────────────────────────────────────────────── + + @When("{string} sets its IoT profile with hubUrl {string} protocols {string} deviceClasses {string}") + public void setsIotProfile(String name, String hubUrl, String protocols, String deviceClasses) { + actionPhase = true; + lastResponse = asTemplateOwner() + .body(Map.of("iotProfile", Map.of( + "hubUrl", hubUrl, + "protocols", List.of(protocols), + "deviceClasses", List.of(deviceClasses) + ))) + .patch("/services/" + serviceIds.get(name)); + currentServiceId = serviceIds.get(name); + } + + @When("{string} updates its IoT profile hubUrl to {string}") + public void updatesIotProfileHubUrl(String name, String hubUrl) { + actionPhase = true; + lastResponse = asTemplateOwner() + .body(Map.of("iotProfile", Map.of("hubUrl", hubUrl))) + .patch("/services/" + serviceIds.get(name)); + currentServiceId = serviceIds.get(name); + } + + @When("{string} patches iotProfile with only hubUrl {string}") + public void patchesIotProfileWithOnlyHubUrl(String name, String hubUrl) { + actionPhase = true; + lastResponse = asTemplateOwner() + .body(Map.of("iotProfile", Map.of("hubUrl", hubUrl))) + .patch("/services/" + serviceIds.get(name)); + } + + @When("^GET /services/\\{smartHubCloudId\\}/replacements\\?iotReady=true is called with no (?:authentication|Authorization) header$") + public void getReplacementsIotReady() { + actionPhase = true; + lastResponse = given().get( + "/services/" + serviceIds.get("SmartHub Cloud") + "/replacements?iotReady=true"); + } + + @When("^GET /services/\\{smartHubCloudId\\}/replacements\\?deviceClass=([^ ]+) is called with no (?:authentication|Authorization) header$") + public void getReplacementsFilteredByDeviceClass(String deviceClass) { + actionPhase = true; + lastResponse = given().get( + "/services/" + serviceIds.get("SmartHub Cloud") + "/replacements?deviceClass=" + deviceClass); + } + + // ── IoT Profile — Then ─────────────────────────────────────────────────── + + @Then("^GET /services/\\{openHubId\\} includes an iotProfile$") + public void getOpenHubIncludesIotProfile() { + given().get("/services/" + serviceIds.get("OpenHub")) + .then().statusCode(200) + .body("iotProfile", notNullValue()); + } + + @Then("the iotProfile.hubUrl is {string}") + public void iotProfileHubUrlIs(String expected) { + given().get("/services/" + currentServiceId) + .then().statusCode(200) + .body("iotProfile.hubUrl", equalTo(expected)); + } + + @Then("the iotProfile.protocols contains {string}") + public void iotProfileProtocolsContains(String protocol) { + given().get("/services/" + currentServiceId) + .then().statusCode(200) + .body("iotProfile.protocols", hasItem(protocol)); + } + + @Then("the candidate {string} has an iotProfile with hubUrl {string}") + public void candidateHasIotProfileWithHubUrl(String name, String hubUrl) { + lastResponse.then().statusCode(200) + .body("candidates.find { it.name == '" + name + "' }.iotProfile.hubUrl", equalTo(hubUrl)); + } } diff --git a/apix-registry/src/test/java/org/botstandards/apix/registry/service/RegistryServiceTest.java b/apix-registry/src/test/java/org/botstandards/apix/registry/service/RegistryServiceTest.java index 9376127..4303cf4 100644 --- a/apix-registry/src/test/java/org/botstandards/apix/registry/service/RegistryServiceTest.java +++ b/apix-registry/src/test/java/org/botstandards/apix/registry/service/RegistryServiceTest.java @@ -79,7 +79,7 @@ class RegistryServiceTest { var req = new ServicePatchRequest( null, null, null, List.of("new.capability"), null, null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null); + null, null, null, null, null, null); assertThat(RegistryService.detectChangeType(ServiceStage.PRODUCTION, null, req)) .isEqualTo(ChangeType.BSM_UPDATED); } @@ -188,6 +188,6 @@ class RegistryServiceTest { private static ServicePatchRequest patch(ServiceStage stage, Boolean locked, Instant sunsetAt, List replaces) { return new ServicePatchRequest( null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, - stage, locked, sunsetAt, null, replaces); + stage, locked, sunsetAt, null, replaces, null); } } diff --git a/apix-registry/src/test/resources/features/iot-profile/iot-profile.feature b/apix-registry/src/test/resources/features/iot-profile/iot-profile.feature new file mode 100644 index 0000000..2e3864d --- /dev/null +++ b/apix-registry/src/test/resources/features/iot-profile/iot-profile.feature @@ -0,0 +1,55 @@ +Feature: IoT device connection profile + As a service provider targeting IoT device portability + I want to declare my device connection profile in the APIX registry + So that migrating IoT devices can discover how to connect to my service + + Background: + Given a deprecated service "SmartHub Cloud" with locked set to false + And a registered service "OpenHub" in stage "PRODUCTION" with O-level "IDENTITY_VERIFIED" + And a registered service "CloudBridge" in stage "PRODUCTION" with O-level "LEGAL_ENTITY_VERIFIED" + + Scenario: Provider adds an IoT profile to their service + When "OpenHub" sets its IoT profile with hubUrl "wss://hub.openhub.io/v2" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub" + Then the response is HTTP 200 + And GET /services/{openHubId} includes an iotProfile + And the iotProfile.hubUrl is "wss://hub.openhub.io/v2" + And the iotProfile.protocols contains "MQTT_5_0" + + Scenario: IoT profile is included in replacement candidates + Given "OpenHub" has IoT profile hubUrl "wss://hub.openhub.io/v2" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub" + And "OpenHub" has declared compatibility with "SmartHub Cloud" + When GET /services/{smartHubCloudId}/replacements is called + Then the response is HTTP 200 + And the candidate "OpenHub" has an iotProfile with hubUrl "wss://hub.openhub.io/v2" + + Scenario: iotReady=true excludes candidates without a profile + Given "OpenHub" has declared compatibility with "SmartHub Cloud" + And "CloudBridge" has IoT profile hubUrl "wss://hub.cloudbridge.io" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub" + And "CloudBridge" has declared compatibility with "SmartHub Cloud" + When GET /services/{smartHubCloudId}/replacements?iotReady=true is called with no authentication header + Then the response is HTTP 200 + And the response contains 1 candidate + And the candidate is "CloudBridge" + + Scenario: deviceClass filter narrows replacement candidates + Given "OpenHub" has IoT profile hubUrl "wss://hub.openhub.io" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub" + And "OpenHub" has declared compatibility with "SmartHub Cloud" + And "CloudBridge" has IoT profile hubUrl "wss://hub.cloudbridge.io" protocols "MQTT_5_0" deviceClasses "device.class.industrial-sensor" + And "CloudBridge" has declared compatibility with "SmartHub Cloud" + When GET /services/{smartHubCloudId}/replacements?deviceClass=device.class.smart-home-hub is called with no authentication header + Then the response is HTTP 200 + And the response contains 1 candidate + And the candidate is "OpenHub" + + Scenario: IoT profile fields are partially updated on subsequent PATCH + Given "OpenHub" has IoT profile hubUrl "wss://old.openhub.io" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub" + When "OpenHub" updates its IoT profile hubUrl to "wss://new.openhub.io" + Then the response is HTTP 200 + And GET /services/{openHubId} includes an iotProfile + And the iotProfile.hubUrl is "wss://new.openhub.io" + And the iotProfile.protocols contains "MQTT_5_0" + + Scenario: Missing required fields on first IoT profile PATCH returns 422 + When "OpenHub" patches iotProfile with only hubUrl "wss://hub.openhub.io" + Then the response is HTTP 422 + And the error message contains "iotProfile.protocols is required" diff --git a/docs/arc42/decisions/ADR-015-iot-transition-anonymity.md b/docs/arc42/decisions/ADR-015-iot-transition-anonymity.md new file mode 100644 index 0000000..e9baf1d --- /dev/null +++ b/docs/arc42/decisions/ADR-015-iot-transition-anonymity.md @@ -0,0 +1,38 @@ +# ADR-015: IoT Device Transition — Pull-Only Anonymity Model + +**Status:** Accepted +**Date:** 2026-05-06 + +## Context + +When a cloud service declares sunset and releases its device lock, IoT device owners need to discover compatible replacement services. APIX facilitates this discovery. The design question is how APIX interacts with device owners during this process without compromising their anonymity. + +Device owner anonymity is a hard requirement: APIX must not store any artefact that links a query to a specific device or owner, including under legal compulsion. + +## Alternatives + +| Alt | Description | Pros | Cons | +|---|---|---|---| +| **A — Pull-only polling** | Devices poll `GET /services/{id}` and `GET /services/{id}/replacements` periodically. No registration. | Zero identity exposure; no per-device APIX state; no single point of notification failure | Device must be online and polling; no guaranteed delivery; detection latency depends on poll interval | +| **B — Anonymous token registration** | Device generates a one-time token and posts it to APIX for push callbacks on status change | Lower detection latency; no active polling required | Token + callback URL creates a device linkage; legally compellable even if hashed; APIX becomes notification infrastructure | +| **C — Email / contact registration** | Device owner registers email with APIX for sunset alerts | Familiar UX for human operators | Directly identifies owner; incompatible with anonymity requirement | +| **D — Third-party privacy relay** | Device owner registers with a privacy relay (e.g. anonymising alias service) which forwards APIX push events | Plausible deniability for owner identity | External dependency; relay is a single point of failure; APIX still stores a callback endpoint linkable to the relay account | + +## Decision + +**Alternative A — Pull-only polling.** + +## Reason + +Only pull-only provides a genuine anonymity guarantee. Alternatives B, C, and D all require APIX to store an artefact (token, address, or endpoint) that is linkable to a device or owner under legal compulsion, regardless of hashing or relay indirection. APIX's anonymity guarantee is only credible if there is no stored linkage at all. + +Device owners requiring active push notification must implement it themselves using their own tooling pointed at the APIX poll endpoints. The poll API is designed to be cache-friendly and bandwidth-minimal. + +## Consequences + +- `GET /services/{id}` must return `locked`, `sunset_date`, and `service_stage` without authentication. +- `GET /services/{id}/replacements` must be publicly accessible without authentication. +- Both endpoints are included in the Caddy cache configuration (same cache policy as the default production service query). +- IP addresses from polling requests must not be persisted beyond standard access log rotation (no analytics pipeline on these endpoints). +- BSF terms of registration require template owners to release the device lock within 90 days of sunset declaration, making the polling window predictable for device owners. +- APIX must never log request parameters or headers that could identify a polling device.