ops: add CI/CD pipeline, a/b rolling deploy, Gitea Actions workflow
Deploy to Production / deploy (push) Failing after 10s

- .gitea/workflows/deploy.yml — push-to-main triggers rolling deploy
- scripts/deploy-bluegreen.sh — a-stack then b-stack restart; Maven runs
  in Docker (no JDK needed on runner host); Caddy reload at end
- scripts/deploy-all.ps1 — emergency manual deploy from dev machine
- infra/docker-compose.yml — a/b pairs per service; wget health checks;
  Gitea service; Prometheus/Grafana/DB ports restricted to localhost
- infra/Caddyfile — dual upstreams with health-based routing
- infra/Dockerfile.* — one per service
- infra/prometheus.yml + grafana provisioning

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Carsten Rehfeld
2026-05-14 14:01:12 +02:00
parent 5156089152
commit 82f0ac6007
72 changed files with 4715 additions and 27 deletions
@@ -0,0 +1,93 @@
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: protocol 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 "WEBSOCKET" deviceClasses "device.class.smart-home-hub"
And "CloudBridge" has declared compatibility with "SmartHub Cloud"
When GET /services/{smartHubCloudId}/replacements?protocol=MQTT_5_0 is called with no authentication header
Then the response is HTTP 200
And the response contains 1 candidate
And the candidate is "OpenHub"
Scenario: Full IoT profile round-trip persists all declared fields
When "OpenHub" sets its full IoT profile
Then the response is HTTP 200
And GET /services/{openHubId} includes an iotProfile
And the iotProfile field "hubUrl" is "wss://hub.openhub.io/v2"
And the iotProfile field "tlsMinVersion" is "1.3"
And the iotProfile field "provisioningEndpoint" is "https://onboard.openhub.io/v1/provision"
And the iotProfile field "credentialFormat" is "JWT"
And the iotProfile field "deviceIdField" is "serialNumber"
And the iotProfile field "minFirmwareVersion" is "2.0.0"
And the iotProfile field "firmwareUpdateUrl" is "https://firmware.openhub.io/update"
And the iotProfile.estimatedMigrationMinutes is 5
Scenario: Device reads IoT connection profile anonymously without leaving a trace
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 with no authentication header
Then the response is HTTP 200
And the candidate "OpenHub" has an iotProfile with hubUrl "wss://hub.openhub.io/v2"
And the response headers contain no Set-Cookie
And the response headers contain a Server-Timing header
Scenario: Server-Timing header is present on replacement search responses
Given "OpenHub" has declared compatibility with "SmartHub Cloud"
When GET /services/{smartHubCloudId}/replacements is called with no authentication header
Then the response is HTTP 200
And the response headers contain a Server-Timing header
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"
@@ -0,0 +1,35 @@
Feature: Device owner anonymity guarantee
As an IoT device owner
I must be able to use the full transition discovery process
Without APIX storing or inferring any artefact that identifies me or my device
Background:
Given a deprecated service "SmartHub Cloud" with locked set to false
And at least one replacement candidate registered
Scenario: Status polling requires no authentication
When GET /services/{smartHubCloudId} is called with no Authorization header
Then the response is HTTP 200
And locked and sunset_date are present in the response body
Scenario: Replacement discovery requires no authentication
When GET /services/{smartHubCloudId}/replacements is called with no Authorization header
Then the response is HTTP 200
And the full replacement list is returned
Scenario: No session state is created during polling
When GET /services/{smartHubCloudId}/replacements is called twice with no shared headers
Then both responses are identical in content
And neither response contains a Set-Cookie header
And neither response contains a session reference
Scenario: Response contains no client-tracking artefacts
When GET /services/{smartHubCloudId}/replacements is called
Then the response headers contain no Set-Cookie
And the response body contains no field that echoes client request details
And the response body contains no correlation ID tied to the caller
Scenario: Polling endpoint is covered by the public cache
When GET /services/{smartHubCloudId}/replacements is called twice within the cache TTL
Then the second response is served from cache
And the cache key does not incorporate any client-identifying header
@@ -0,0 +1,50 @@
Feature: Service decommissioning and historical record preservation
As a template owner
I want to decommission a service after its sunset date
And as a device owner I must be able to access the historical record indefinitely
Background:
Given a registered service "SmartHub Cloud" with endpoint "https://api.smarthub.example"
And the service has capability "device.telemetry"
And the service has locked set to true
And the service is in stage "DEPRECATED" with a sunset_date set
And the service has locked set to false
And a registered service "OpenHub" with endpoint "https://api.openhub.example"
And "OpenHub" has declared compatibility with "SmartHub Cloud"
And the sunset_date of "SmartHub Cloud" has passed
Scenario: Template owner decommissions the service
When the template owner sets service_stage to "DECOMMISSIONED"
Then the service does not appear in any capability search results
And GET /services/{id} returns HTTP 200 with the complete historical record
And the response body contains service_stage "DECOMMISSIONED"
And a version history entry of type "STAGE_CHANGED" exists for the service
Scenario: Decommissioned service with unreleased lock auto-releases for replacement discovery
Given a deprecated service "NeverReleased" that reached DECOMMISSIONED without setting locked=false
When GET /services/{neverReleasedId}/replacements is called with no authentication header
Then the response is HTTP 200
And replacement candidates are returned regardless of the stored locked value
Scenario: Historical record survives indefinitely
Given a decommissioned service
When GET /services/{id} is called
Then the response is HTTP 200
And the full BSM payload is present in the response
And all version history entries are accessible via GET /services/{id}/history
Scenario: Cannot decommission a service before its sunset date
Given a deprecated service "FutureCloud" with a sunset_date 30 days from now
When the template owner attempts to set service_stage to "DECOMMISSIONED"
Then the response is HTTP 422
And the error message contains "sunset_at has not passed"
Scenario: Full transition timeline is visible in version history
Given "SmartHub Cloud" has completed the full lifecycle
When GET /services/{id}/history is called
Then the history contains an entry of type "REGISTERED"
And the history contains an entry of type "SUNSET_DECLARED"
And the history contains an entry of type "LOCK_RELEASED"
And the history contains an entry of type "REPLACEMENT_DECLARED"
And the history contains an entry of type "STAGE_CHANGED" with new value "DECOMMISSIONED"
And all entries are ordered chronologically ascending
@@ -0,0 +1,46 @@
Feature: Replacement provider declares compatibility
As a replacement service provider
I want to declare that my service covers a deprecated template
So that IoT device owners can discover me as a migration target
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"
Scenario: Replacement provider declares compatibility with a deprecated service
When "OpenHub" declares replacesServiceIds containing the ID of "SmartHub Cloud"
Then GET /services/{smartHubCloudId}/replacements includes "OpenHub"
And a version history entry of type "REPLACEMENT_DECLARED" exists for "SmartHub Cloud"
And the service_replacements table contains the declared pair
Scenario: Declaration against a non-deprecated service is rejected
Given a service "ActiveCloud" in stage "PRODUCTION" with locked set to true
When "OpenHub" declares replacesServiceIds containing the ID of "ActiveCloud"
Then the response is HTTP 422
And the error message contains "target service is not deprecated"
Scenario: Declaration against a locked deprecated service is rejected
Given a service "LockedCloud" in stage "DEPRECATED" with locked set to true
When "OpenHub" declares replacesServiceIds containing the ID of "LockedCloud"
Then the response is HTTP 422
And the error message contains "target service lock has not been released"
Scenario: Multiple replacement providers for the same deprecated service
Given a service "CloudBridge" in stage "PRODUCTION" with O-level "LEGAL_ENTITY_VERIFIED"
When "OpenHub" declares replacesServiceIds containing the ID of "SmartHub Cloud"
And "CloudBridge" declares replacesServiceIds containing the ID of "SmartHub Cloud"
Then GET /services/{smartHubCloudId}/replacements returns 2 candidates
And "CloudBridge" appears before "OpenHub" in the results
And the ordering is by O-level descending
Scenario: Replacement provider retracts their compatibility declaration
Given "OpenHub" has declared compatibility with "SmartHub Cloud"
When "OpenHub" removes "SmartHub Cloud" from its replacesServiceIds
Then GET /services/{smartHubCloudId}/replacements no longer includes "OpenHub"
And the service_replacements row for this pair is deleted
Scenario: Duplicate declaration is idempotent
Given "OpenHub" has declared compatibility with "SmartHub Cloud"
When "OpenHub" declares replacesServiceIds containing the ID of "SmartHub Cloud" again
Then the response is HTTP 200
And GET /services/{smartHubCloudId}/replacements still returns exactly 1 candidate
@@ -0,0 +1,45 @@
Feature: Device owner discovers replacement services
As an IoT device owner
I want to discover compatible replacement services by polling APIX
Without revealing my identity or device details
Background:
Given a deprecated service "SmartHub Cloud" with locked set to false and a sunset_date set
And "OpenHub" in stage "PRODUCTION" with O-level "IDENTITY_VERIFIED" has declared compatibility
And "CloudBridge" in stage "PRODUCTION" with O-level "LEGAL_ENTITY_VERIFIED" has declared compatibility
Scenario: Device polls service status without authentication
When GET /services/{smartHubCloudId} is called with no authentication header
Then the response is HTTP 200
And the response body contains service_stage "DEPRECATED"
And the response body contains locked false
And the response body contains a sunset_date
Scenario: Device discovers replacement candidates without authentication
When GET /services/{smartHubCloudId}/replacements is called with no authentication header
Then the response is HTTP 200
And the response contains 2 candidates
And "CloudBridge" appears before "OpenHub" in the results
Scenario: Device filters candidates by minimum O-level
When GET /services/{smartHubCloudId}/replacements?minOLevel=LEGAL_ENTITY_VERIFIED is called with no authentication header
Then the response is HTTP 200
And the response contains 1 candidate
And the candidate is "CloudBridge"
Scenario: Device polls a still-locked deprecated service
Given a deprecated service "LockedCloud" with locked set to true
When GET /services/{lockedCloudId}/replacements is called with no authentication header
Then the response is HTTP 200
And the response body contains an empty candidates list
And the response body contains locked true
Scenario: Default production search excludes deprecated services
When GET /services?capability=device.telemetry is called with no authentication header
Then "SmartHub Cloud" is not in the results
And only services with stage "PRODUCTION" are returned
Scenario: Explicit deprecated filter includes deprecated services
When GET /services?capability=device.telemetry&stage=deprecated is called
Then "SmartHub Cloud" is in the results
And each result has service_stage "DEPRECATED"
@@ -0,0 +1,40 @@
Feature: Sunset declaration and lock release
As a template owner
I want to declare a sunset date and release the device lock
So that IoT device owners can prepare for migration in a predictable window
Background:
Given a registered service "SmartHub Cloud" with endpoint "https://api.smarthub.example"
And the service has capability "device.telemetry"
And the service is in stage "PRODUCTION"
And the service has locked set to true
Scenario: Template owner declares sunset date
When the template owner updates the service with sunset_date 90 days from now and stage "DEPRECATED"
Then the service stage is "DEPRECATED"
And the service has a sunset_date set
And a version history entry of type "SUNSET_DECLARED" exists for the service
And the service does not appear in default production search results for capability "device.telemetry"
And the service appears in search results when stage filter is "deprecated"
Scenario: Sunset declaration preserves the device lock
When the template owner updates the service with sunset_date 90 days from now and stage "DEPRECATED"
Then the service has locked set to true
And GET /services/{id}/replacements returns HTTP 200 with an empty list
Scenario: Template owner releases the device lock after sunset is declared
Given the service is in stage "DEPRECATED" with a sunset_date set
When the template owner sets locked to false
Then the service has locked set to false
And a version history entry of type "LOCK_RELEASED" exists for the service
And the version history entry contains the previous locked value true
Scenario: Lock cannot be released without a prior sunset declaration
When the template owner attempts to set locked to false without a sunset_date
Then the response is HTTP 422
And the error message contains "sunset_at required before lock release"
Scenario: Sunset date cannot be set in the past
When the template owner attempts to set sunset_date to yesterday
Then the response is HTTP 422
And the error message contains "sunset_at must be a future moment"
@@ -0,0 +1,87 @@
Feature: Organisation audit log
# Immutable, append-only trail of all events affecting an organisation.
# Two authorised views: org owner (transparency) and BSF admin (yearly reporting).
# Unauthenticated callers are rejected.
Background:
Given the organisation registry is empty
# ── Access control ────────────────────────────────────────────────────────────
Scenario: Org owner can read their own audit log
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner requests the audit log
Then the response status is 200
And the audit log contains at least 1 event
Scenario: BSF admin can read any org's audit log
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When a BSF admin requests the audit log
Then the response status is 200
And the audit log contains at least 1 event
Scenario: Unauthenticated caller cannot read the audit log
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When an unauthenticated caller requests the audit log
Then the response status is 403
# ── Registration events ────────────────────────────────────────────────────────
Scenario: Audit log records registration
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner requests the audit log
Then the audit log contains a "REGISTERED" event triggered by "SYSTEM"
# ── Key rotation events — TAN path ────────────────────────────────────────────
Scenario: TAN issuance is recorded in audit log
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 the owner requests the audit log
Then the audit log contains a "TAN_ISSUED" event triggered by "SYSTEM"
Scenario: TAN-based key rotation completion is recorded in audit log
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 the owner confirms key rotation with the TAN
And the owner requests the audit log with the new api key
Then the audit log contains a "KEY_ROTATED" event triggered by "OWNER"
# ── Key rotation events — DNS path ────────────────────────────────────────────
Scenario: DNS challenge initiation is recorded in audit log
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the agent has initiated DNS-challenge key rotation
When the owner requests the audit log
Then the audit log contains a "DNS_ROTATION_INITIATED" event triggered by "SYSTEM"
Scenario: DNS-challenge key rotation completion is recorded in audit log
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 the agent confirms DNS-challenge key rotation
And the owner requests the audit log with the new api key
Then the audit log contains a "KEY_ROTATED" event triggered by "OWNER"
# ── Fraud lock events ─────────────────────────────────────────────────────────
Scenario: Fraud report is recorded in audit log
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner reports key rotation fraud using the registered email
When the owner requests the audit log
Then the audit log contains a "FRAUD_REPORTED" event triggered by "OWNER"
Scenario: Fraud lock clearance is recorded in audit log
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner reports key rotation fraud using the registered email
When a BSF admin clears the fraud lock
And a BSF admin requests the audit log
Then the audit log contains a "FRAUD_LOCK_CLEARED" event triggered by "BSF_ADMIN"
# ── Event ordering ─────────────────────────────────────────────────────────────
Scenario: Audit log returns events newest first
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 the owner requests the audit log
Then the first audit event is "TAN_ISSUED"
@@ -0,0 +1,153 @@
Feature: BSF admin actions — temp grants, revocation, TAN-based key rotation
Background:
Given the organisation registry is empty
# ── Temporary O-level grants ──────────────────────────────────────────────────
Scenario: BSF admin grants a temporary elevated O-level
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
When a BSF admin grants a temporary level "OPERATIONALLY_VERIFIED" expiring in 30 days
Then the response status is 200
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
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
And the caller reads the organisation
Then the effective O-level is "IDENTITY_VERIFIED"
Scenario: BSF admin revokes an active temporary grant
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 30 days
When a BSF admin revokes the temporary grant
Then the response status is 200
And the effective O-level is "IDENTITY_VERIFIED"
Scenario: Revoking a non-existent temporary grant returns 422
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
When a BSF admin revokes the temporary grant
Then the response status is 422
Scenario: Temp grant with expiry in the past is rejected
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
When a BSF admin grants a temporary level "OPERATIONALLY_VERIFIED" expiring in the past
Then the response status is 422
Scenario: Temp grant request with wrong admin key is rejected
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
When an unauthorised caller grants a temporary level "OPERATIONALLY_VERIFIED"
Then the response status is 403
# ── Admin: manual O-level assignment ─────────────────────────────────────────
Scenario: BSF admin assigns earned level manually (O-4 post review)
Given an organisation is registered with target level "OPERATIONALLY_VERIFIED" for domain "example.com"
When a BSF admin assigns earned level "OPERATIONALLY_VERIFIED"
Then the response status is 200
And the earned O-level is "OPERATIONALLY_VERIFIED"
And the verification status is "ACHIEVED"
# ── Self-service key rotation (2FA: rotation secret + email TAN) ────────────────
Scenario: Owner rotates keys — rotation secret triggers TAN, TAN completes rotation
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner initiates key rotation using the rotation secret
Then the response status is 200
And the response contains a key rotation warning with the org name
And a key rotation TAN has been sent
When 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
And the old api key is no longer valid
Scenario: Key rotation initiation with wrong rotation secret is rejected
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
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
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
And the owner confirms key rotation with the TAN
Then the response status is 422
# ── Fraud report ─────────────────────────────────────────────────────────────
Scenario: Legitimate owner reports a key rotation they did not initiate
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner reports key rotation fraud using the registered email
Then the response status is 200
And the response confirms the fraud notification was received
Scenario: Fraud report with unrecognised email returns generic confirmation
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When a caller reports key rotation fraud using "unknown@other.com"
Then the response status is 200
And the response confirms the fraud notification was received
Scenario: Fraud report cancels the pending TAN and locks the org against further rotation
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 the owner reports key rotation fraud using the registered email
Then the response status is 200
And the response confirms the fraud notification was received
When the owner confirms key rotation with the TAN
Then the response status is 422
Scenario: After fraud lock, rotation secret cannot initiate a new rotation
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner reports key rotation fraud using the registered email
When the owner initiates key rotation using the rotation secret
Then the response status is 422
Scenario: BSF admin clears fraud lock after investigation
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner reports key rotation fraud using the registered email
When a BSF admin clears the fraud lock
Then the response status is 200
And the org is no longer fraud-locked
When the owner initiates key rotation using the rotation secret
Then the response status is 200
# ── Emergency TAN-based key rotation ─────────────────────────────────────────
Scenario: Owner requests a TAN and uses it to rotate keys
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner requests a TAN using the registered email
Then the response status is 200
And the response message confirms TAN was sent
When 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: TAN request with unrecognised email returns generic message
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner requests a TAN using "unknown@other.com"
Then the response status is 200
And the response message confirms TAN was sent
Scenario: TAN is rejected after its 5-minute validity window
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
And the owner uses the TAN to rotate keys
Then the response status is 422
Scenario: TAN request rate limit — more than 3 requests within 24 hours is rejected
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 the owner requests a TAN using the registered email
Then the response status is 422
Scenario: TAN request counter resets after 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
And the owner requests a TAN using the registered email
Then the response status is 200
@@ -0,0 +1,93 @@
Feature: DNS-challenge key rotation — bot-friendly ACME DNS-01 pattern
# Primary automated rotation path for agents with DNS API access.
# Two factors: rotation secret (proves legitimate requester) + DNS TXT control
# (proves domain ownership). No email parsing, no human inbox dependency.
#
# DNS record to publish: _apix-rotation.{domain} TXT "apix-rotate={challengeToken}"
# Window: 15 minutes (accommodates DNS API propagation + polling cycle)
# The challenge token is intentionally public it has no value without the rotation secret.
Background:
Given the organisation registry is empty
# ── Happy path ──────────────────────────────────────────────────────────────
Scenario: Agent initiates DNS-challenge rotation and confirms after publishing DNS record
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the agent initiates DNS-challenge key rotation
Then the response status is 200
And the response contains the dns record name "_apix-rotation.example.com"
And the response contains the challenge token and dns value format
And the response contains an expiry timestamp
Given the agent has published the rotation challenge to dns
When 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
And the old api key is no longer valid
# ── Failure paths ────────────────────────────────────────────────────────────
Scenario: Confirm fails when DNS record has not been published
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the agent has initiated DNS-challenge key rotation
And the DNS rotation challenge record is absent
When the agent confirms DNS-challenge key rotation
Then the response status is 422
Scenario: Confirm fails when challenge token does not match DNS record
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the agent has initiated DNS-challenge key rotation
And the DNS record "_apix-rotation.example.com" contains the wrong challenge token
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
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
And the agent confirms DNS-challenge key rotation
Then the response status is 422
Scenario: Initiate fails with wrong rotation secret
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the agent initiates DNS-challenge key rotation with an invalid rotation secret
Then the response status is 403
Scenario: Confirm fails with wrong rotation secret
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 the agent confirms DNS-challenge key rotation with an invalid rotation secret
Then the response status is 403
Scenario: Confirm without a prior initiate is rejected
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the agent confirms DNS-challenge key rotation
Then the response status is 422
# ── Fraud lock interaction ────────────────────────────────────────────────────
Scenario: Fraud-locked org cannot initiate DNS-challenge rotation
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner reports key rotation fraud using the registered email
When the agent initiates DNS-challenge key rotation
Then the response status is 422
Scenario: Fraud-locked org cannot confirm DNS-challenge rotation
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
And the owner reports key rotation fraud using the registered email
When the agent confirms DNS-challenge key rotation
Then the response status is 422
# ── Isolation between paths ───────────────────────────────────────────────────
Scenario: DNS challenge and email TAN are independent — each has its own pending state
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 the agent initiates DNS-challenge key rotation
Then the response status is 200
And the response contains the dns record name "_apix-rotation.example.com"
@@ -0,0 +1,50 @@
Feature: Organisation O-level transitions — owner-initiated upgrades and re-verification
Background:
Given the organisation registry is empty
Scenario: Owner upgrades target level from O-0 to O-1 and triggers verification successfully
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the DoH MX record "example.com" returns at least one entry
When the owner requests an upgrade to target level "IDENTITY_VERIFIED"
Then the response status is 200
And the verification status is "PENDING"
And a dns verification token is returned
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
When the owner triggers verification
Then the response status is 200
And the earned O-level is "IDENTITY_VERIFIED"
And the verification status is "ACHIEVED"
Scenario: Owner cannot downgrade target level below earned level
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
When the owner requests an upgrade to target level "UNVERIFIED"
Then the response status is 422
Scenario: Owner cannot set target level equal to earned level
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
When the owner requests an upgrade to target level "IDENTITY_VERIFIED"
Then the response status is 422
Scenario: Re-verification after previous failure resets the failure state
Given an organisation is registered with target level "IDENTITY_VERIFIED" for domain "example.com"
And the DoH TXT record "_apix-verification.example.com" returns no records
And the DoH MX record "example.com" returns at least one entry
When the owner triggers verification
Then the verification status is "FAILED"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
When the owner triggers verification
Then the response status is 200
And the earned O-level is "IDENTITY_VERIFIED"
And the verification status is "ACHIEVED"
Scenario: Upgrade to O-4 sets status to MANUAL_REVIEW
Given an organisation has earned O-level "HYGIENE_VERIFIED" with target "HYGIENE_VERIFIED"
When the owner requests an upgrade to target level "OPERATIONALLY_VERIFIED"
Then the response status is 200
And the verification status is "MANUAL_REVIEW"
Scenario: Requesting upgrade with wrong API key is rejected
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner requests an upgrade to target level "IDENTITY_VERIFIED" with an invalid api key
Then the response status is 403
@@ -0,0 +1,63 @@
Feature: Organisation registration
Background:
Given the organisation registry is empty
Scenario: Register with target O-0 (unverified) is immediately achieved
When a service registers with target level "UNVERIFIED"
Then the response status is 201
And the response contains an organisation id
And the response contains an api key with prefix "apix_org_"
And the response contains a rotation secret with prefix "apix_rot_"
And the earned O-level is "UNVERIFIED"
And the verification status is "ACHIEVED"
And no dns verification token is returned
Scenario: Register with target O-1 (identity verified) returns pending with DNS token
When a service registers with target level "IDENTITY_VERIFIED"
Then the response status is 201
And the earned O-level is "UNVERIFIED"
And the verification status is "PENDING"
And a dns verification token is returned
Scenario: Register with target O-2 (legal entity verified) returns pending with DNS token
When a service registers with target level "LEGAL_ENTITY_VERIFIED"
Then the response status is 201
And the earned O-level is "UNVERIFIED"
And the verification status is "PENDING"
And a dns verification token is returned
Scenario: Register with target O-3 (hygiene verified) returns pending with DNS token
When a service registers with target level "HYGIENE_VERIFIED"
Then the response status is 201
And the earned O-level is "UNVERIFIED"
And the verification status is "PENDING"
And a dns verification token is returned
Scenario: Register with target O-4 (operationally verified) goes to manual review
When a service registers with target level "OPERATIONALLY_VERIFIED"
Then the response status is 201
And the earned O-level is "UNVERIFIED"
And the verification status is "MANUAL_REVIEW"
Scenario: Register with target O-5 (audited) goes to manual review
When a service registers with target level "AUDITED"
Then the response status is 201
And the earned O-level is "UNVERIFIED"
And the verification status is "MANUAL_REVIEW"
Scenario: Registration fails when registrant name is missing
When a service registers without a registrant name
Then the response status is 400
Scenario: Registration fails when email is invalid
When a service registers with an invalid email
Then the response status is 400
Scenario: Registration fails when domain is missing
When a service registers without a domain
Then the response status is 400
Scenario: Registration fails when target O-level is missing
When a service registers without a target O-level
Then the response status is 400
@@ -0,0 +1,57 @@
Feature: Organisation verification — O-1 Identity Verified (DNS)
Background:
Given the organisation registry is empty
Scenario: Verification succeeds when DNS TXT and MX records are present
Given an organisation is registered with target level "IDENTITY_VERIFIED" for domain "example.com"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
When the owner triggers verification
Then the response status is 200
And the earned O-level is "IDENTITY_VERIFIED"
And the verification status is "ACHIEVED"
Scenario: Verification fails when DNS TXT record is absent
Given an organisation is registered with target level "IDENTITY_VERIFIED" for domain "example.com"
And the DoH TXT record "_apix-verification.example.com" returns no records
And the DoH MX record "example.com" returns at least one entry
When the owner triggers verification
Then the response status is 200
And the earned O-level is "UNVERIFIED"
And the verification status is "FAILED"
And the verification step is "DNS_TXT"
Scenario: Verification fails when DNS TXT token does not match
Given an organisation is registered with target level "IDENTITY_VERIFIED" for domain "example.com"
And the DoH TXT record "_apix-verification.example.com" returns "wrong-token"
And the DoH MX record "example.com" returns at least one entry
When the owner triggers verification
Then the response status is 200
And the earned O-level is "UNVERIFIED"
And the verification status is "FAILED"
And the verification step is "DNS_TXT"
Scenario: Verification fails when MX record is absent
Given an organisation is registered with target level "IDENTITY_VERIFIED" for domain "example.com"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns no records
When the owner triggers verification
Then the response status is 200
And the earned O-level is "UNVERIFIED"
And the verification status is "FAILED"
And the verification step is "DNS_MX"
Scenario: Verify is idempotent when status is already ACHIEVED
Given an organisation is registered with target level "IDENTITY_VERIFIED" for domain "example.com"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
When the owner triggers verification
And the owner triggers verification again
Then the response status is 200
And the verification status is "ACHIEVED"
Scenario: Verification with wrong API key is rejected
Given an organisation is registered with target level "IDENTITY_VERIFIED" for domain "example.com"
When the owner triggers verification with an invalid api key
Then the response status is 403
@@ -0,0 +1,48 @@
Feature: Organisation verification — O-2 Legal Entity Verified (GLEIF / OpenCorporates)
Background:
Given the organisation registry is empty
Scenario: Verification succeeds via GLEIF when LEI is found for registrant name
Given an organisation is registered with target level "LEGAL_ENTITY_VERIFIED" for domain "example.com" with name "Acme Corp"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
And the GLEIF API returns an LEI "529900HNOAA1KXQJUQ27" for name "Acme Corp"
When the owner triggers verification
Then the response status is 200
And the earned O-level is "LEGAL_ENTITY_VERIFIED"
And the verification status is "ACHIEVED"
And the detected LEI is "529900HNOAA1KXQJUQ27"
Scenario: Verification falls back to OpenCorporates when GLEIF returns no results
Given an organisation is registered with target level "LEGAL_ENTITY_VERIFIED" for domain "example.com" with name "Local Gmbh"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
And the GLEIF API returns no results for name "Local Gmbh"
And the OpenCorporates API returns a company match for name "Local Gmbh"
When the owner triggers verification
Then the response status is 200
And the earned O-level is "LEGAL_ENTITY_VERIFIED"
And the verification status is "ACHIEVED"
Scenario: Verification fails when neither GLEIF nor OpenCorporates finds the company
Given an organisation is registered with target level "LEGAL_ENTITY_VERIFIED" for domain "example.com" with name "Ghost Inc"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
And the GLEIF API returns no results for name "Ghost Inc"
And the OpenCorporates API returns no results for name "Ghost Inc"
When the owner triggers verification
Then the response status is 200
And the earned O-level is "IDENTITY_VERIFIED"
And the verification status is "FAILED"
And the verification step is "OPENCORPORATES"
Scenario: Verification fails at O-1 DNS step and does not reach GLEIF
Given an organisation is registered with target level "LEGAL_ENTITY_VERIFIED" for domain "example.com" with name "Acme Corp"
And the DoH TXT record "_apix-verification.example.com" returns no records
And the DoH MX record "example.com" returns at least one entry
When the owner triggers verification
Then the response status is 200
And the earned O-level is "UNVERIFIED"
And the verification status is "FAILED"
And the verification step is "DNS_TXT"
@@ -0,0 +1,59 @@
Feature: Organisation verification — O-3 Hygiene Verified (security.txt, DMARC, SPF)
Background:
Given the organisation registry is empty
Scenario: Hygiene verification succeeds when all three hygiene checks pass
Given an organisation is registered with target level "HYGIENE_VERIFIED" for domain "example.com" with name "Acme Corp"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
And the GLEIF API returns an LEI "529900HNOAA1KXQJUQ27" for name "Acme Corp"
And the security.txt endpoint for "example.com" returns HTTP 200
And the DoH TXT record "_dmarc.example.com" returns a valid DMARC policy
And the DoH TXT record "example.com" returns a valid SPF record
When the owner triggers verification
Then the response status is 200
And the earned O-level is "HYGIENE_VERIFIED"
And the verification status is "ACHIEVED"
Scenario: Hygiene verification fails when security.txt is missing
Given an organisation is registered with target level "HYGIENE_VERIFIED" for domain "example.com" with name "Acme Corp"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
And the GLEIF API returns an LEI "529900HNOAA1KXQJUQ27" for name "Acme Corp"
And the security.txt endpoint for "example.com" returns HTTP 404
And the DoH TXT record "_dmarc.example.com" returns a valid DMARC policy
And the DoH TXT record "example.com" returns a valid SPF record
When the owner triggers verification
Then the response status is 200
And the earned O-level is "LEGAL_ENTITY_VERIFIED"
And the verification status is "FAILED"
And the verification step is "SECURITY_TXT"
Scenario: Hygiene verification fails when DMARC record is absent
Given an organisation is registered with target level "HYGIENE_VERIFIED" for domain "example.com" with name "Acme Corp"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
And the GLEIF API returns an LEI "529900HNOAA1KXQJUQ27" for name "Acme Corp"
And the security.txt endpoint for "example.com" returns HTTP 200
And the DoH TXT record "_dmarc.example.com" returns no records
And the DoH TXT record "example.com" returns a valid SPF record
When the owner triggers verification
Then the response status is 200
And the earned O-level is "LEGAL_ENTITY_VERIFIED"
And the verification status is "FAILED"
And the verification step is "DMARC"
Scenario: Hygiene verification fails when SPF record is absent
Given an organisation is registered with target level "HYGIENE_VERIFIED" for domain "example.com" with name "Acme Corp"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
And the GLEIF API returns an LEI "529900HNOAA1KXQJUQ27" for name "Acme Corp"
And the security.txt endpoint for "example.com" returns HTTP 200
And the DoH TXT record "_dmarc.example.com" returns a valid DMARC policy
And the DoH TXT record "example.com" returns no records
When the owner triggers verification
Then the response status is 200
And the earned O-level is "LEGAL_ENTITY_VERIFIED"
And the verification status is "FAILED"
And the verification step is "SPF"