{"openapi":"3.1.0","info":{"title":"ALYT Platform API","version":"0.1.0","description":"The ALYT developer API. Authenticate with `Authorization: Bearer <alyt_dev_...>` (a key) or a developers.alyt.com session. `x-alyt-scope` on each operation is the scope a key needs; `session` marks operations only a signed-in person may call."},"servers":[{"url":"https://developers.alyt.com/api/v1","description":"Production"}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"Access token from POST /auth/login. Short-lived; refresh with POST /auth/refresh, which rotates the token and revokes the whole family if a token is ever reused."}},"schemas":{"AuthLoginResponse":{"type":"object","properties":{"accessToken":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"type":"integer"}},"required":["accessToken","refreshToken","expiresIn"],"additionalProperties":false},"AuthMeClaims":{"type":"object","properties":{"sub":{"type":"string","description":"The user id."},"email":{"type":"string"},"platformRole":{"type":"string","enum":["OWNER","STAFF","SUPPORT"]},"tenants":{"type":"object","additionalProperties":{"type":"string","enum":["ADMIN","INSTALLER"]},"description":"Tenant memberships, keyed by tenantId."},"clients":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/AuthMeClaimsClientsValue"},"description":"Client (household) memberships, keyed by clientId."},"iat":{"type":"integer"},"exp":{"type":"integer"},"jti":{"type":"string"}},"required":["sub","email","tenants","clients","iat","exp","jti"],"additionalProperties":false},"AuthMeClaimsClientsValue":{"type":"object","properties":{"role":{"type":"string","enum":["OWNER","MEMBER","GUEST"]},"tenantId":{"type":"string"},"propertyId":{"type":"string"}},"required":["role","tenantId"],"additionalProperties":false},"AuthMeResponse":{"type":"object","properties":{"claims":{"$ref":"#/components/schemas/AuthMeClaims"},"persona":{"type":"string","enum":["platform","tenant","installer","client"],"description":"Which console this user should land on."},"preferences":{"type":"object","properties":{"units":{"type":["string","null"],"enum":["metric","imperial"]}},"additionalProperties":false}},"required":["claims","persona","preferences"],"additionalProperties":false},"AuthResetPasswordRequest":{"type":"object","properties":{"token":{"type":"string","minLength":16},"password":{"type":"string"}},"required":["token","password"],"additionalProperties":false},"CapabilitiesListItem":{"type":"object","properties":{"id":{"type":"string","pattern":"^[a-z_]+(?:\\.[a-z_]+)?$"},"version":{"type":"integer","minimum":0},"label":{"type":"string","minLength":1},"icon":{"type":"string","pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"},"controls":{"type":"array","items":{"$ref":"#/components/schemas/CapabilitiesListItemControlsItem"}},"roomActions":{"type":"array","items":{"$ref":"#/components/schemas/CapabilitiesListItemRoomActionsItem"}}},"required":["id","version","label","icon","controls","roomActions"],"additionalProperties":false},"CapabilitiesListItemControlsItem":{"type":"object","properties":{"key":{"type":"string","minLength":1},"primitive":{"type":"string","minLength":1},"label":{"type":"string","minLength":1},"icon":{"type":"string","pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"},"unit":{"type":"string"},"min":{"type":"number"},"max":{"type":"number"},"step":{"type":"number","minimum":0},"options":{"type":"array","items":{"$ref":"#/components/schemas/CapabilitiesListItemControlsItemOptionsItem"}},"readable":{"type":"boolean"},"writable":{"type":"boolean"},"binding":{"type":"string","enum":["state","settings"]},"group":{"type":"string","enum":["primary","settings","diagnostic"]},"order":{"type":"integer"},"requires":{"type":"string"},"confirm":{"type":"boolean"}},"required":["key","primitive","label","readable","writable","binding","group","order"],"additionalProperties":false},"CapabilitiesListItemControlsItemOptionsItem":{"type":"object","properties":{"value":{"type":"string"},"label":{"type":"string"},"icon":{"type":"string","pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"}},"required":["value","label"],"additionalProperties":false},"CapabilitiesListItemRoomActionsItem":{"type":"object","properties":{"action":{"type":"string","minLength":1},"label":{"type":"string","minLength":1},"primitive":{"type":"string","enum":["toggle","lock","slider","stepper"]},"controlKey":{"type":"string","minLength":1},"icon":{"type":"string","pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"},"confirm":{"type":"boolean"}},"required":["action","label","primitive","controlKey","icon"],"additionalProperties":false},"CatalogueDefinitionsItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"semver":{"type":"string"},"descriptor":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptor"},"prose":{"type":"string"},"aiDeclaration":{"type":"string","enum":["none","alyt","own_cloud","own_local"]},"author":{"$ref":"#/components/schemas/CatalogueDefinitionsItemAuthor"},"publishedAt":{"type":"string","format":"date-time"}},"required":["id","slug","name","semver","descriptor","prose","aiDeclaration","author","publishedAt"],"additionalProperties":false},"CatalogueDefinitionsItemAuthor":{"type":"object","properties":{"displayName":{"type":"string"},"type":{"type":"string","enum":["INDIVIDUAL","BRAND","INTEGRATOR"]},"verified":{"type":"boolean"}},"required":["displayName","type","verified"],"additionalProperties":false},"CatalogueDefinitionsItemDescriptor":{"type":"object","properties":{"kind":{"type":"string","pattern":"^[A-Z0-9_]{2,40}$"},"name":{"type":"string","minLength":1},"icon":{"type":"string","pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"},"promise":{"type":"string","minLength":1},"connectivity":{"type":"string","enum":["local","cloud","cloud-then-local"]},"brings":{"type":"array","items":{"type":"string","enum":["light","switch","thermostat","lock","cover","fan","camera","speaker","vacuum","irrigation","hub","voice","energy","button","sensor","appliance","other"]}},"connect":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptorConnect"},"discovery":{"type":["object","null"],"properties":{"verb":{"type":"string","minLength":1}},"required":["verb"],"additionalProperties":false},"disconnect":{"type":"object","properties":{"consequence":{"type":"string","minLength":1}},"required":["consequence"],"additionalProperties":false},"docsUrl":{"type":"string","format":"uri"},"pairing":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptorPairing"},"builtIn":{"type":"object","properties":{"reason":{"type":"string","minLength":1}},"required":["reason"],"additionalProperties":false},"requires":{"type":"array","items":{"type":"string"}},"author":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptorAuthor"},"version":{"type":"string","minLength":1},"source":{"type":"string","enum":["built-in","installed"]},"visibility":{"type":"string","enum":["public","tenant"]},"verification":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptorVerification"},"listed":{"type":"boolean"}},"required":["kind","name","icon","promise","connectivity","brings","connect","discovery","disconnect","author","version","source","visibility","verification","listed"],"additionalProperties":false},"CatalogueDefinitionsItemDescriptorAuthor":{"type":"object","properties":{"kind":{"type":"string","enum":["alyt","brand","maker","company"]},"name":{"type":"string","minLength":1},"url":{"type":"string","format":"uri"}},"required":["kind","name"],"additionalProperties":false},"CatalogueDefinitionsItemDescriptorConnect":{"type":"object","properties":{"method":{"type":"string","enum":["discover","form","signIn","oauth","pair"]},"fields":{"type":"array","items":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptorConnectFieldsItem"}},"help":{"type":"string"}},"required":["method","fields"],"additionalProperties":false},"CatalogueDefinitionsItemDescriptorConnectFieldsItem":{"type":"object","properties":{"key":{"type":"string","minLength":1},"label":{"type":"string","minLength":1},"kind":{"type":"string","enum":["text","password","email","select"]},"secret":{"type":"boolean"},"required":{"type":"boolean"},"placeholder":{"type":"string"},"help":{"type":"string"},"options":{"type":"array","items":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptorConnectFieldsItemOptionsItem"}}},"required":["key","label","kind","secret","required"],"additionalProperties":false},"CatalogueDefinitionsItemDescriptorConnectFieldsItemOptionsItem":{"type":"object","properties":{"value":{"type":"string"},"label":{"type":"string"}},"required":["value","label"],"additionalProperties":false},"CatalogueDefinitionsItemDescriptorPairing":{"type":"object","properties":{"joinSeconds":{"type":"integer","minimum":0},"intro":{"type":"string","minLength":1},"vendors":{"type":"array","items":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptorPairingVendorsItem"},"minItems":1},"supported":{"type":"boolean"}},"required":["joinSeconds","intro","vendors","supported"],"additionalProperties":false},"CatalogueDefinitionsItemDescriptorPairingVendorsItem":{"type":"object","properties":{"id":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,40}$"},"name":{"type":"string","minLength":1},"devices":{"type":"string","minLength":1},"matchVendors":{"type":"array","items":{"type":"string","minLength":1}},"fresh":{"type":"string","minLength":1},"reset":{"type":"array","items":{"type":"string","minLength":1}},"pair":{"type":"array","items":{"type":"string","minLength":1}},"note":{"type":"string"},"actions":{"type":"array","items":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptorPairingVendorsItemActionsItem"}},"source":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptorPairingVendorsItemSource"}},"required":["id","name","devices","matchVendors","reset","pair","source"],"additionalProperties":false},"CatalogueDefinitionsItemDescriptorPairingVendorsItemActionsItem":{"type":"object","properties":{"id":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,40}$"},"label":{"type":"string","minLength":1},"help":{"type":"string"},"fields":{"type":"array","items":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptorConnectFieldsItem"}},"done":{"type":"string","minLength":1}},"required":["id","label","fields","done"],"additionalProperties":false},"CatalogueDefinitionsItemDescriptorPairingVendorsItemSource":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"verifiedAt":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"}},"required":["url","verifiedAt"],"additionalProperties":false},"CatalogueDefinitionsItemDescriptorVerification":{"type":"object","properties":{"level":{"type":"string","enum":["proven","declared"]},"at":{"type":"string","format":"date-time"},"note":{"type":"string"}},"required":["level"],"additionalProperties":false},"ClientsListItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"tenantId":{"type":"string","format":"uuid"},"name":{"type":"string"},"email":{"type":["string","null"]},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["id","tenantId","name","email","createdAt","updatedAt"],"additionalProperties":false},"DeveloperAuthLoginResponse":{"type":"object","properties":{"accessToken":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"type":"integer"},"kind":{"const":"developer"}},"required":["accessToken","refreshToken","expiresIn","kind"],"additionalProperties":false},"DevelopersAdminListItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"userId":{"type":"string","format":"uuid"},"email":{"type":"string","format":"email"},"type":{"type":"string","enum":["INDIVIDUAL","BRAND","INTEGRATOR"]},"displayName":{"type":"string"},"organisation":{"type":["string","null"]},"website":{"type":["string","null"]},"status":{"type":"string","enum":["PENDING_EMAIL","ACTIVE","SUSPENDED"]},"sandboxTenantId":{"type":["string","null"],"format":"uuid"},"verifiedAt":{"type":["string","null"],"format":"date-time"},"mfaEnabledAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":"string","format":"date-time"}},"required":["id","userId","email","type","displayName","organisation","website","status","sandboxTenantId","verifiedAt","mfaEnabledAt","createdAt"],"additionalProperties":false},"DevelopersAdminQuotaDefaultsValue":{"type":"object","properties":{"ceiling":{"type":"integer","minimum":0},"window":{"type":"string","enum":["day","month"]},"burstPerMinute":{"type":"integer","minimum":1}},"required":["ceiling","window"],"additionalProperties":false},"DevelopersCreateKeyResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"kind":{"type":"string","enum":["DEV","LIVE"]},"name":{"type":"string"},"prefix":{"type":"string"},"scopes":{"type":"array","items":{"type":"string","enum":["tenant:read","rooms:write","devices:read","devices:write","devices:commands","telemetry:read","things:read","things:control","integrations:read","integrations:write","notifications:read","notifications:write","ai:chat","simulator:write","webhooks:write","events:read","firmware:read"]}},"definitionId":{"type":["string","null"],"format":"uuid"},"createdAt":{"type":"string","format":"date-time"},"expiresAt":{"type":["string","null"],"format":"date-time"},"lastUsedAt":{"type":["string","null"],"format":"date-time"},"revokedAt":{"type":["string","null"],"format":"date-time"},"rotatedFromId":{"type":["string","null"],"format":"uuid"},"secret":{"type":"string"}},"required":["id","kind","name","prefix","scopes","definitionId","createdAt","expiresAt","lastUsedAt","revokedAt","rotatedFromId","secret"],"additionalProperties":false},"DevelopersKeysItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"kind":{"type":"string","enum":["DEV","LIVE"]},"name":{"type":"string"},"prefix":{"type":"string"},"scopes":{"type":"array","items":{"type":"string","enum":["tenant:read","rooms:write","devices:read","devices:write","devices:commands","telemetry:read","things:read","things:control","integrations:read","integrations:write","notifications:read","notifications:write","ai:chat","simulator:write","webhooks:write","events:read","firmware:read"]}},"definitionId":{"type":["string","null"],"format":"uuid"},"createdAt":{"type":"string","format":"date-time"},"expiresAt":{"type":["string","null"],"format":"date-time"},"lastUsedAt":{"type":["string","null"],"format":"date-time"},"revokedAt":{"type":["string","null"],"format":"date-time"},"rotatedFromId":{"type":["string","null"],"format":"uuid"}},"required":["id","kind","name","prefix","scopes","definitionId","createdAt","expiresAt","lastUsedAt","revokedAt","rotatedFromId"],"additionalProperties":false},"DevicesCommandOption1Payload":{"type":"object","properties":{"capability":{"type":"string","pattern":"^[a-z_]+(?:\\.[a-z_]+)?$"},"key":{"type":"string","minLength":1,"maxLength":80},"value":{}},"required":["capability","key"],"additionalProperties":false},"DevicesListItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"tenantId":{"type":"string","format":"uuid"},"propertyId":{"type":["string","null"],"format":"uuid"},"roomId":{"type":["string","null"],"format":"uuid"},"name":{"type":"string"},"kind":{"type":"string","enum":["VOICE_SATELLITE","HUB","SENSOR","AGENT","OTHER"]},"capabilities":{"type":"array","items":{"type":"string","enum":["VOICE","AUDIO_OUT","HUB","SENSOR","COFFEE_MACHINE","NETWORK","LIGHT","SWITCH","THERMOSTAT","LOCK","COVER","FAN","CAMERA","MEDIA_PLAYER","ENERGY","BUTTON","VACUUM","IRRIGATION","BATTERY"]}},"features":{"type":"array","items":{"type":"string"}},"currentUnitId":{"type":["string","null"],"format":"uuid"},"parentDeviceId":{"type":["string","null"],"format":"uuid"},"protocol":{"type":["string","null"],"enum":["MATTER","ZIGBEE","THREAD"]},"protocolId":{"type":["string","null"]},"firmwareChannel":{"type":"string","enum":["STABLE","BETA"]},"status":{"type":"string","enum":["PROVISIONING","ONLINE","OFFLINE","DISABLED","AWAITING_REPLACEMENT"]},"lastSeen":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"settings":{"type":["object","null"],"properties":{"deviceId":{"type":"string","format":"uuid"},"byCapability":{"type":"object","additionalProperties":{},"description":"Keyed by capability, lower-cased. A capability the device lacks has no key."},"enabled":{"type":"boolean"},"updatedAt":{"type":"string","format":"date-time"}},"required":["deviceId","byCapability","enabled","updatedAt"],"additionalProperties":false},"currentUnit":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"deviceId":{"type":"string","format":"uuid"},"serial":{"type":"string"},"model":{"type":["string","null"]},"firmware":{"type":["string","null"]},"macAddress":{"type":["string","null"]},"installedAt":{"type":"string","format":"date-time"},"removedAt":{"type":["string","null"],"format":"date-time"},"removedReason":{"type":["string","null"]}},"required":["id","deviceId","serial","model","firmware","macAddress","installedAt","removedAt","removedReason"],"additionalProperties":false},"reported":{"type":["object","null"],"properties":{"deviceId":{"type":"string","format":"uuid"},"payload":{},"reportedAt":{"type":"string","format":"date-time"}},"required":["deviceId","payload","reportedAt"],"additionalProperties":false},"liveness":{"$ref":"#/components/schemas/DevicesListItemLiveness"},"thing":{"$ref":"#/components/schemas/DevicesListItemThing"}},"required":["id","tenantId","propertyId","roomId","name","kind","capabilities","features","currentUnitId","parentDeviceId","protocol","protocolId","firmwareChannel","status","lastSeen","deletedAt","createdAt","updatedAt","settings","currentUnit","reported","liveness","thing"],"additionalProperties":false},"DevicesListItemLiveness":{"type":"object","properties":{"state":{"type":"string","enum":["online","not_heard_from","offline","provisioning","disabled","awaiting_replacement"]},"lastSeen":{"type":["string","null"],"format":"date-time"},"secondsSinceLastSeen":{"type":["number","null"]},"windowSeconds":{"type":"integer","description":"The heartbeat window the answer was judged against, in seconds."}},"required":["state","lastSeen","secondsSinceLastSeen","windowSeconds"],"additionalProperties":false},"DevicesListItemThing":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"kind":{"type":"string","enum":["device","endpoint"]},"name":{"type":"string"},"roomId":{"type":["string","null"],"format":"uuid"},"category":{"type":"string","enum":["light","switch","thermostat","lock","cover","fan","camera","speaker","vacuum","irrigation","hub","voice","energy","button","sensor","appliance","other"]},"icon":{"type":"string","pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"},"capabilities":{"type":"array","items":{"$ref":"#/components/schemas/CapabilitiesListItem"}},"state":{"type":"object","additionalProperties":{}},"stateUpdatedAt":{"type":["string","null"],"format":"date-time"},"liveness":{"type":["object","null"],"properties":{"state":{"type":"string"},"lastSeen":{"type":["string","null"],"format":"date-time"},"secondsSinceLastSeen":{"type":["number","null"]},"windowSeconds":{"type":"number"}},"required":["state","lastSeen","secondsSinceLastSeen"],"additionalProperties":false},"settings":{"type":"object","additionalProperties":{}},"presentedAs":{"type":["string","null"],"enum":["light","switch"]},"presentable":{"type":"boolean"}},"required":["id","kind","name","roomId","category","icon","capabilities","state","stateUpdatedAt","liveness","settings"],"additionalProperties":false},"EndpointsUpdateResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"tenantId":{"type":"string","format":"uuid"},"integrationId":{"type":"string","format":"uuid"},"roomId":{"type":["string","null"],"format":"uuid"},"kind":{"type":"string","enum":["SPEAKER","LIGHT","SENSOR","OTHER"]},"externalId":{"type":"string","description":"Provider-native stable id, such as a Sonos RINCON uuid. Never an IP address."},"name":{"type":"string"},"address":{"type":["string","null"]},"capabilities":{"type":["object","null"],"additionalProperties":{}},"capabilitySet":{"type":"array","items":{"type":"string","enum":["VOICE","AUDIO_OUT","HUB","SENSOR","COFFEE_MACHINE","NETWORK","LIGHT","SWITCH","THERMOSTAT","LOCK","COVER","FAN","CAMERA","MEDIA_PLAYER","ENERGY","BUTTON","VACUUM","IRRIGATION","BATTERY"]}},"features":{"type":"array","items":{"type":"string"}},"presentedAs":{"type":["string","null"]},"state":{"type":"object","additionalProperties":{},"description":"Live values the connector last reported, keyed by capability."},"stateUpdatedAt":{"type":["string","null"],"format":"date-time"},"lastSeen":{"type":["string","null"],"format":"date-time"}},"required":["id","tenantId","integrationId","roomId","kind","externalId","name","address","capabilities","capabilitySet","features","state","stateUpdatedAt","lastSeen"],"additionalProperties":false},"FirmwareListItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"kind":{"type":"string","enum":["VOICE_SATELLITE","HUB","SENSOR","OTHER"]},"version":{"type":"string"},"url":{"type":"string"},"sha256":{"type":"string"},"notes":{"type":["string","null"]},"mandatory":{"type":"boolean"},"channel":{"type":"string","enum":["STABLE","BETA"]},"createdAt":{"type":"string","format":"date-time"}},"required":["id","kind","version","url","sha256","notes","mandatory","channel","createdAt"],"additionalProperties":false},"IntegrationsConnectChallenge":{"type":"object","properties":{"token":{"type":"string"},"input":{"type":"object","additionalProperties":{"type":"string"}}},"required":["token","input"],"additionalProperties":false},"IntegrationsConnectOption1":{"type":"object","properties":{"status":{"const":"connected"},"integrationId":{"type":"string","format":"uuid"},"things":{"type":"integer"},"discovering":{"type":"boolean"}},"required":["status","integrationId","things"],"additionalProperties":false},"IntegrationsConnectOption2":{"type":"object","properties":{"status":{"const":"challenge"},"token":{"type":"string"},"prompt":{"type":"string"},"fields":{"type":"array","items":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptorConnectFieldsItem"}}},"required":["status","token","prompt","fields"],"additionalProperties":false},"IntegrationsConnectOption3":{"type":"object","properties":{"status":{"const":"rejected"},"reason":{"type":"string"}},"required":["status","reason"],"additionalProperties":false},"IntegrationsListEndpointsResponse":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/EndpointsUpdateResponse"}},"nextCursor":{"type":["string","null"]}},"required":["items","nextCursor"],"additionalProperties":false},"PropertiesDashboardPinsItemOption1":{"type":"object","properties":{"kind":{"const":"thing"},"thing":{"$ref":"#/components/schemas/PropertiesDashboardPinsItemOption1Thing"}},"required":["kind","thing"],"additionalProperties":false},"PropertiesDashboardPinsItemOption1Thing":{"type":"object","properties":{"kind":{"type":"string","enum":["device","endpoint"]},"id":{"type":"string","format":"uuid"}},"required":["kind","id"],"additionalProperties":false},"PropertiesDashboardPinsItemOption2":{"type":"object","properties":{"kind":{"const":"routine"},"routineId":{"type":"string","format":"uuid"}},"required":["kind","routineId"],"additionalProperties":false},"PropertiesDashboardResponse":{"type":"object","properties":{"propertyId":{"type":"string","format":"uuid"},"pins":{"type":"array","items":{"anyOf":[{"$ref":"#/components/schemas/PropertiesDashboardPinsItemOption1"},{"$ref":"#/components/schemas/PropertiesDashboardPinsItemOption2"}]},"maxItems":40}},"required":["propertyId","pins"],"additionalProperties":false},"PropertiesListItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"tenantId":{"type":"string","format":"uuid"},"clientId":{"type":"string","format":"uuid"},"name":{"type":"string"},"timezone":{"type":"string"},"address":{"type":["string","null"]},"latitude":{"type":["number","null"]},"longitude":{"type":["number","null"]},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["id","tenantId","clientId","name","timezone","address","latitude","longitude","createdAt","updatedAt"],"additionalProperties":false},"PropertiesOverviewHouse":{"type":"object","properties":{"primaryActions":{"type":"array","items":{"$ref":"#/components/schemas/PropertiesOverviewHousePrimaryActionsItem"}},"actionValues":{"type":"array","items":{"$ref":"#/components/schemas/PropertiesOverviewHouseActionValuesItem"}}},"required":["primaryActions","actionValues"],"additionalProperties":false},"PropertiesOverviewHouseActionValuesItem":{"type":"object","properties":{"capability":{"type":"string","pattern":"^[a-z_]+(?:\\.[a-z_]+)?$"},"action":{"type":"string"},"value":{},"members":{"type":"integer"}},"required":["capability","action","value","members"],"additionalProperties":false},"PropertiesOverviewHousePrimaryActionsItem":{"type":"object","properties":{"capability":{"type":"string","pattern":"^[a-z_]+(?:\\.[a-z_]+)?$"},"action":{"type":"string"}},"required":["capability","action"],"additionalProperties":false},"PropertiesOverviewProperty":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"timezone":{"type":"string"}},"required":["id","name","timezone"],"additionalProperties":false},"PropertiesOverviewRoomsItem":{"type":"object","properties":{"icon":{"type":"string","pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"},"listening":{"type":"boolean"},"primaryActions":{"type":"array","items":{"$ref":"#/components/schemas/PropertiesOverviewHousePrimaryActionsItem"}},"actionValues":{"type":"array","items":{"$ref":"#/components/schemas/PropertiesOverviewHouseActionValuesItem"}},"chosenActions":{"type":["array","null"],"items":{"$ref":"#/components/schemas/PropertiesOverviewHousePrimaryActionsItem"}},"availableActions":{"type":"array","items":{"$ref":"#/components/schemas/PropertiesOverviewHousePrimaryActionsItem"}},"id":{"type":"string","format":"uuid"},"tenantId":{"type":"string","format":"uuid"},"propertyId":{"type":"string","format":"uuid"},"name":{"type":"string"},"quietHoursStart":{"type":["integer","null"]},"quietHoursEnd":{"type":["integer","null"]},"things":{"type":"array","items":{"$ref":"#/components/schemas/DevicesListItemThing"}},"devices":{"type":"array","items":{"type":"object","additionalProperties":{}}},"endpoints":{"type":"array","items":{"type":"object","additionalProperties":{}}}},"required":["icon","listening","primaryActions","actionValues","chosenActions","availableActions","id","tenantId","propertyId","name","quietHoursStart","quietHoursEnd","things","devices","endpoints"],"additionalProperties":false},"PropertiesRunActionFailedItem":{"type":"object","properties":{"thingId":{"type":"string"},"name":{"type":"string"},"reason":{"type":"string"}},"required":["thingId","name","reason"],"additionalProperties":false},"PropertiesRunActionRequest":{"type":"object","properties":{"capability":{"type":"string","pattern":"^[a-z_]+(?:\\.[a-z_]+)?$"},"action":{"type":"string","minLength":1,"maxLength":80},"value":{},"tenantId":{"type":"string","format":"uuid"}},"required":["capability","action","tenantId"],"additionalProperties":false},"PropertiesRunActionResponse":{"type":"object","properties":{"applied":{"type":"integer"},"failed":{"type":"array","items":{"$ref":"#/components/schemas/PropertiesRunActionFailedItem"}},"skipped":{"type":"integer"}},"required":["applied","failed","skipped"],"additionalProperties":false},"ReviewQueueItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"definitionId":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"semver":{"type":"string"},"state":{"type":"string","enum":["DRAFT","IN_REVIEW","CHANGES_REQUESTED","APPROVED","PUBLISHED","DEPRECATED","REJECTED"]},"distribution":{"type":"string","enum":["PUBLIC","PRIVATE"]},"descriptor":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptor"},"prose":{"type":"string"},"runtime":{"$ref":"#/components/schemas/ReviewQueueItemRuntime"},"aiDeclaration":{"type":"string","enum":["none","alyt","own_cloud","own_local"]},"changelog":{"type":"string"},"checklist":{"type":["array","null"],"items":{"$ref":"#/components/schemas/ReviewQueueItemChecklistItem"}},"probe":{"type":["array","null"],"items":{"$ref":"#/components/schemas/ReviewQueueItemProbeItem"}},"reviewNotes":{"type":"array","items":{"$ref":"#/components/schemas/ReviewQueueItemReviewNotesItem"}},"hasTestInput":{"type":"boolean"},"submittedAt":{"type":["string","null"],"format":"date-time"},"reviewedAt":{"type":["string","null"],"format":"date-time"},"publishedAt":{"type":["string","null"],"format":"date-time"},"deprecatedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"developer":{"$ref":"#/components/schemas/ReviewQueueItemDeveloper"},"previous":{"type":["object","null"],"properties":{"semver":{"type":"string"},"descriptor":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptor"}},"required":["semver","descriptor"],"additionalProperties":false}},"required":["id","definitionId","slug","name","semver","state","distribution","descriptor","prose","runtime","aiDeclaration","changelog","checklist","probe","reviewNotes","hasTestInput","submittedAt","reviewedAt","publishedAt","deprecatedAt","createdAt","updatedAt","developer","previous"],"additionalProperties":false},"ReviewQueueItemChecklistItem":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string"},"ok":{"type":"boolean"},"detail":{"type":"string"}},"required":["id","label","ok"],"additionalProperties":false},"ReviewQueueItemDeveloper":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"displayName":{"type":"string"},"type":{"type":"string","enum":["INDIVIDUAL","BRAND","INTEGRATOR"]},"verifiedAt":{"type":["string","null"],"format":"date-time"},"email":{"type":"string"}},"required":["id","displayName","type","verifiedAt","email"],"additionalProperties":false},"ReviewQueueItemProbeItem":{"type":"object","properties":{"step":{"type":"string","enum":["health","connect","discover","state","control"]},"ok":{"type":"boolean"},"status":{"type":["integer","null"]},"ms":{"type":"integer"},"detail":{"type":"string"}},"required":["step","ok","status","ms"],"additionalProperties":false},"ReviewQueueItemReviewNotesItem":{"type":"object","properties":{"at":{"type":"string","format":"date-time"},"by":{"type":"string"},"text":{"type":"string"}},"required":["at","by","text"],"additionalProperties":false},"ReviewQueueItemRuntime":{"type":"object","properties":{"kind":{"type":"string","enum":["remote-http","platform-container"]},"url":{"type":"string"}},"required":["kind"],"additionalProperties":false},"RolloutStartResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["ACTIVE","PAUSED","CANCELLED","COMPLETED"]},"total":{"type":"integer","description":"Devices of the image's kind on its channel, minus any AWAITING_REPLACEMENT."},"updated":{"type":"integer","description":"Units reporting the target version."},"failed":{"type":"integer","description":"Stored failures whose unit is still behind."},"pending":{"type":"integer"}},"required":["id","status","total","updated","failed","pending"],"additionalProperties":false},"RoomsListItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"tenantId":{"type":"string","format":"uuid"},"propertyId":{"type":"string","format":"uuid"},"name":{"type":"string"},"quietHoursStart":{"type":["integer","null"]},"quietHoursEnd":{"type":["integer","null"]},"icon":{"type":["string","null"]},"listening":{"type":"boolean"},"primaryActions":{"type":["array","null"],"items":{"$ref":"#/components/schemas/RoomsListItemPrimaryActionsItem"}},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["id","tenantId","propertyId","name","quietHoursStart","quietHoursEnd","icon","listening","primaryActions","createdAt","updatedAt"],"additionalProperties":false},"RoomsListItemPrimaryActionsItem":{"type":"object","properties":{"capability":{"type":"string","pattern":"^[a-z_]+(?:\\.[a-z_]+)?$"},"action":{"type":"string","minLength":1}},"required":["capability","action"],"additionalProperties":false},"RoutinesListItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"tenantId":{"type":"string","format":"uuid"},"propertyId":{"type":"string","format":"uuid"},"name":{"type":"string","minLength":1,"maxLength":80},"icon":{"type":["string","null"],"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"},"enabled":{"type":"boolean"},"when":{"type":"array","items":{"anyOf":[{"$ref":"#/components/schemas/RoutinesListItemWhenItemOption1"},{"$ref":"#/components/schemas/RoutinesListItemWhenItemOption2"},{"$ref":"#/components/schemas/RoutinesListItemWhenItemOption3"},{"$ref":"#/components/schemas/RoutinesListItemWhenItemOption4"},{"$ref":"#/components/schemas/RoutinesListItemWhenItemOption5"},{"type":"object","properties":{"kind":{"const":"manual"}},"required":["kind"],"additionalProperties":false,"description":"A button on the console, the phone or the skill: `POST /routines/:id/run`."}]},"maxItems":10},"only":{"type":"array","items":{"anyOf":[{"$ref":"#/components/schemas/RoutinesListItemOnlyItemOption1"},{"$ref":"#/components/schemas/RoutinesListItemOnlyItemOption2"},{"$ref":"#/components/schemas/RoutinesListItemOnlyItemOption3"},{"$ref":"#/components/schemas/RoutinesListItemOnlyItemOption4"},{"$ref":"#/components/schemas/RoutinesListItemOnlyItemOption5"}]},"maxItems":10},"do":{"type":"array","items":{"anyOf":[{"$ref":"#/components/schemas/RoutinesListItemDoItemOption1"},{"$ref":"#/components/schemas/RoutinesListItemDoItemOption2"},{"$ref":"#/components/schemas/RoutinesListItemDoItemOption3"},{"$ref":"#/components/schemas/RoutinesListItemDoItemOption4"},{"$ref":"#/components/schemas/RoutinesListItemDoItemOption5"},{"$ref":"#/components/schemas/RoutinesListItemDoItemOption6"}]},"minItems":1,"maxItems":30},"lastRunAt":{"type":["string","null"],"format":"date-time"},"runCount":{"type":"integer"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["id","tenantId","propertyId","name","icon","enabled","when","only","do","lastRunAt","runCount","createdAt","updatedAt"],"additionalProperties":false},"RoutinesListItemDoItemOption1":{"type":"object","properties":{"kind":{"const":"room"},"roomId":{"type":["string","null"],"format":"uuid"},"capability":{"type":"string","pattern":"^[a-z_]+(?:\\.[a-z_]+)?$"},"action":{"type":"string","minLength":1,"maxLength":80,"description":"A room action of that capability, as `POST /rooms/:id/actions` takes it."},"value":{}},"required":["kind","roomId","capability","action"],"additionalProperties":false},"RoutinesListItemDoItemOption2":{"type":"object","properties":{"kind":{"const":"thing"},"thing":{"$ref":"#/components/schemas/PropertiesDashboardPinsItemOption1Thing"},"command":{"type":"string","enum":["set","press"]},"capability":{"type":"string","pattern":"^[a-z_]+(?:\\.[a-z_]+)?$"},"key":{"type":"string","minLength":1,"maxLength":80},"value":{}},"required":["kind","thing","command","capability","key"],"additionalProperties":false},"RoutinesListItemDoItemOption3":{"type":"object","properties":{"kind":{"const":"say"},"roomId":{"type":"string","format":"uuid"},"text":{"type":"string","minLength":1,"maxLength":400},"volume":{"type":"integer","minimum":0,"maximum":100}},"required":["kind","roomId","text"],"additionalProperties":false,"description":"Spoken in the room by the assistant's voice; silent in the room's quiet hours."},"RoutinesListItemDoItemOption4":{"type":"object","properties":{"kind":{"const":"wait"},"seconds":{"type":"integer","minimum":1,"maximum":300}},"required":["kind","seconds"],"additionalProperties":false},"RoutinesListItemDoItemOption5":{"type":"object","properties":{"kind":{"const":"notify"},"title":{"type":"string","minLength":1,"maxLength":80},"body":{"type":"string","minLength":1,"maxLength":400}},"required":["kind","title","body"],"additionalProperties":false,"description":"A household notification, on the household channel."},"RoutinesListItemDoItemOption6":{"type":"object","properties":{"kind":{"const":"routine"},"routineId":{"type":"string","format":"uuid"}},"required":["kind","routineId"],"additionalProperties":false,"description":"Runs another routine's actions as one step, three levels deep at most."},"RoutinesListItemOnlyItemOption1":{"type":"object","properties":{"kind":{"const":"time"},"from":{"type":"string","pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","description":"A local time of the property, HH:MM."},"to":{"type":"string","pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","description":"A local time of the property, HH:MM."}},"required":["kind","from","to"],"additionalProperties":false,"description":"Half-open, may cross midnight; from equal to to means all day."},"RoutinesListItemOnlyItemOption2":{"type":"object","properties":{"kind":{"const":"days"},"days":{"type":"array","items":{"type":"string","enum":["mon","tue","wed","thu","fri","sat","sun"]},"minItems":1,"maxItems":7}},"required":["kind","days"],"additionalProperties":false},"RoutinesListItemOnlyItemOption3":{"type":"object","properties":{"kind":{"const":"sun"},"is":{"type":"string","enum":["day","night"]}},"required":["kind","is"],"additionalProperties":false},"RoutinesListItemOnlyItemOption4":{"type":"object","properties":{"kind":{"const":"state"},"thing":{"$ref":"#/components/schemas/PropertiesDashboardPinsItemOption1Thing"},"capability":{"type":"string","pattern":"^[a-z_]+(?:\\.[a-z_]+)?$"},"key":{"type":"string","minLength":1,"maxLength":80,"description":"The control key inside the capability, as `stateKey` reads it."},"op":{"type":"string","enum":["eq","ne","gt","gte","lt","lte"]},"value":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"}]}},"required":["kind","thing","capability","key","op","value"],"additionalProperties":false},"RoutinesListItemOnlyItemOption5":{"type":"object","properties":{"kind":{"const":"presence"},"who":{"type":"string","enum":["anyone","nobody"]},"is":{"type":"string","enum":["home","away"]}},"required":["kind","who","is"],"additionalProperties":false},"RoutinesListItemWhenItemOption1":{"type":"object","properties":{"kind":{"const":"phrase"},"text":{"type":"string","minLength":1,"maxLength":120,"description":"Said to the assistant, or sent by the Alexa skill; matched on the words, not the punctuation."}},"required":["kind","text"],"additionalProperties":false},"RoutinesListItemWhenItemOption2":{"type":"object","properties":{"kind":{"const":"time"},"at":{"type":"string","pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","description":"A local time of the property, HH:MM."},"days":{"type":"array","items":{"type":"string","enum":["mon","tue","wed","thu","fri","sat","sun"]},"minItems":1,"maxItems":7}},"required":["kind","at"],"additionalProperties":false},"RoutinesListItemWhenItemOption3":{"type":"object","properties":{"kind":{"const":"sun"},"event":{"type":"string","enum":["sunrise","sunset"]},"offsetMinutes":{"type":"integer","minimum":-180,"maximum":180}},"required":["kind","event"],"additionalProperties":false},"RoutinesListItemWhenItemOption4":{"type":"object","properties":{"kind":{"const":"state"},"thing":{"$ref":"#/components/schemas/PropertiesDashboardPinsItemOption1Thing"},"capability":{"type":"string","pattern":"^[a-z_]+(?:\\.[a-z_]+)?$"},"key":{"type":"string","minLength":1,"maxLength":80,"description":"The control key inside the capability, as `stateKey` reads it."},"op":{"type":"string","enum":["eq","ne","gt","gte","lt","lte"]},"value":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"}]}},"required":["kind","thing","capability","key","op","value"],"additionalProperties":false,"description":"Fires when the test becomes true, not on every report while it stays true."},"RoutinesListItemWhenItemOption5":{"type":"object","properties":{"kind":{"const":"presence"},"who":{"type":"string","enum":["anyone","nobody"]},"becomes":{"type":"string","enum":["home","away"]}},"required":["kind","who","becomes"],"additionalProperties":false},"SimulatorListItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"deviceId":{"type":"string","format":"uuid"},"tenantId":{"type":"string","format":"uuid"},"serial":{"type":"string"},"name":{"type":"string"},"profile":{"type":"string","enum":["HUB","SPEAKER","LIGHT","SENSOR","THERMOSTAT"]},"scenario":{"type":"string","enum":["NORMAL","OFFLINE","LOW_BATTERY","SENSOR_ERROR","BURST","FLAPPING","SLOW"]},"scenarioUntil":{"type":["string","null"],"format":"date-time"},"connected":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"}},"required":["id","deviceId","tenantId","serial","name","profile","scenario","scenarioUntil","connected","createdAt"],"additionalProperties":false},"StreamStreamResponse":{"type":"object","properties":{"id":{"type":"string","pattern":"^\\d+$"},"type":{"type":"string","enum":["device.online","device.offline","device.telemetry","command.result","thing.state","notification.raised","submission.state","webhook.ping"]},"tenantId":{"type":"string","format":"uuid"},"createdAt":{"type":"string","format":"date-time"},"data":{"type":"object","additionalProperties":{}}},"required":["id","type","tenantId","createdAt","data"],"additionalProperties":false},"SubmissionsCreateRuntime":{"type":"object","properties":{"kind":{"const":"remote-http"},"url":{"type":"string","format":"uri","maxLength":300}},"required":["kind","url"],"additionalProperties":false},"SubmissionsListItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"definitionId":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"semver":{"type":"string"},"state":{"type":"string","enum":["DRAFT","IN_REVIEW","CHANGES_REQUESTED","APPROVED","PUBLISHED","DEPRECATED","REJECTED"]},"distribution":{"type":"string","enum":["PUBLIC","PRIVATE"]},"descriptor":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptor"},"prose":{"type":"string"},"runtime":{"$ref":"#/components/schemas/ReviewQueueItemRuntime"},"aiDeclaration":{"type":"string","enum":["none","alyt","own_cloud","own_local"]},"changelog":{"type":"string"},"checklist":{"type":["array","null"],"items":{"$ref":"#/components/schemas/ReviewQueueItemChecklistItem"}},"probe":{"type":["array","null"],"items":{"$ref":"#/components/schemas/ReviewQueueItemProbeItem"}},"reviewNotes":{"type":"array","items":{"$ref":"#/components/schemas/ReviewQueueItemReviewNotesItem"}},"hasTestInput":{"type":"boolean"},"submittedAt":{"type":["string","null"],"format":"date-time"},"reviewedAt":{"type":["string","null"],"format":"date-time"},"publishedAt":{"type":["string","null"],"format":"date-time"},"deprecatedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["id","definitionId","slug","name","semver","state","distribution","descriptor","prose","runtime","aiDeclaration","changelog","checklist","probe","reviewNotes","hasTestInput","submittedAt","reviewedAt","publishedAt","deprecatedAt","createdAt","updatedAt"],"additionalProperties":false},"TenantsImportClientsItem":{"type":"object","properties":{"ref":{"type":"string","minLength":1},"name":{"type":"string","minLength":1,"maxLength":200},"email":{"type":["string","null"]}},"required":["ref","name","email"],"additionalProperties":false},"TenantsImportDevicesItem":{"type":"object","properties":{"ref":{"type":"string","minLength":1},"name":{"type":"string","minLength":1,"maxLength":200},"kind":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"features":{"type":"array","items":{"type":"string"}},"property":{"type":["string","null"],"minLength":1},"room":{"type":["string","null"],"minLength":1},"parent":{"type":["string","null"],"minLength":1},"protocol":{"type":["string","null"]},"protocolId":{"type":["string","null"]},"firmwareChannel":{"type":"string"},"settings":{"type":["object","null"],"properties":{"byCapability":{},"enabled":{"type":"boolean"}},"required":["byCapability","enabled"],"additionalProperties":false},"units":{"type":"array","items":{"$ref":"#/components/schemas/TenantsImportDevicesItemUnitsItem"}}},"required":["ref","name","kind","capabilities","features","property","room","parent","protocol","protocolId","firmwareChannel","settings","units"],"additionalProperties":false},"TenantsImportDevicesItemUnitsItem":{"type":"object","properties":{"serial":{"type":"string"},"model":{"type":["string","null"]},"firmware":{"type":["string","null"]},"macAddress":{"type":["string","null"]},"current":{"type":"boolean"}},"required":["serial","model","firmware","macAddress","current"],"additionalProperties":false},"TenantsImportEndpointsItem":{"type":"object","properties":{"integration":{"type":"string","minLength":1},"externalId":{"type":"string"},"name":{"type":"string","minLength":1,"maxLength":200},"kind":{"type":"string","enum":["SPEAKER","LIGHT","SENSOR","OTHER"]},"room":{"type":["string","null"],"minLength":1},"address":{"type":["string","null"]},"capabilities":{"anyOf":[{},{"type":"null"}]},"capabilitySet":{"type":"array","items":{"type":"string"}},"features":{"type":"array","items":{"type":"string"}},"presentedAs":{"type":["string","null"]}},"required":["integration","externalId","name","kind","room","address","capabilities","capabilitySet","features","presentedAs"],"additionalProperties":false},"TenantsImportIntegrationsItem":{"type":"object","properties":{"ref":{"type":"string","minLength":1},"kind":{"type":"string"},"name":{"type":"string","minLength":1,"maxLength":200},"definition":{"$ref":"#/components/schemas/TenantsImportIntegrationsItemDefinition"},"config":{"type":"object","additionalProperties":{}},"redactedFields":{"type":"array","items":{"type":"string"}}},"required":["ref","kind","name","definition","config","redactedFields"],"additionalProperties":false},"TenantsImportIntegrationsItemDefinition":{"type":"object","properties":{"slug":{"type":"string"},"semver":{"type":"string"}},"required":["slug","semver"],"additionalProperties":false},"TenantsImportPropertiesItem":{"type":"object","properties":{"ref":{"type":"string","minLength":1},"name":{"type":"string","minLength":1,"maxLength":200},"timezone":{"type":"string"},"client":{"type":"string","minLength":1},"rooms":{"type":"array","items":{"$ref":"#/components/schemas/TenantsImportPropertiesItemRoomsItem"}}},"required":["ref","name","timezone","client","rooms"],"additionalProperties":false},"TenantsImportPropertiesItemRoomsItem":{"type":"object","properties":{"ref":{"type":"string","minLength":1},"name":{"type":"string","minLength":1,"maxLength":200},"icon":{"type":["string","null"]},"listening":{"type":"boolean"},"quietHoursStart":{"type":["integer","null"]},"quietHoursEnd":{"type":["integer","null"]},"primaryActions":{"anyOf":[{},{"type":"null"}]}},"required":["ref","name","icon","listening","quietHoursStart","quietHoursEnd","primaryActions"],"additionalProperties":false},"TenantsImportRequest":{"type":"object","properties":{"format":{"const":"alyt-tenant-export"},"version":{"const":1},"exportedAt":{"type":"string","format":"date-time"},"tenant":{"$ref":"#/components/schemas/TenantsImportTenant"},"clients":{"type":"array","items":{"$ref":"#/components/schemas/TenantsImportClientsItem"}},"properties":{"type":"array","items":{"$ref":"#/components/schemas/TenantsImportPropertiesItem"}},"integrations":{"type":"array","items":{"$ref":"#/components/schemas/TenantsImportIntegrationsItem"}},"endpoints":{"type":"array","items":{"$ref":"#/components/schemas/TenantsImportEndpointsItem"}},"devices":{"type":"array","items":{"$ref":"#/components/schemas/TenantsImportDevicesItem"}}},"required":["format","version","exportedAt","tenant","clients","properties","integrations","endpoints","devices"],"additionalProperties":false},"TenantsImportTenant":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"code":{"type":"string","pattern":"^[A-Z0-9]{3}$"},"cloudInferenceOptIn":{"type":"boolean"},"storeTranscriptsOptIn":{"type":"boolean"}},"required":["name","code","cloudInferenceOptIn","storeTranscriptsOptIn"],"additionalProperties":false},"TenantsListItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"code":{"type":"string","minLength":3,"maxLength":3,"description":"The 3-character code device serials carry."},"status":{"type":"string","enum":["ACTIVE","SUSPENDED"]},"placeholder":{"type":"boolean","description":"True when created automatically from an unknown serial code, before the brand subscribed."},"cloudInferenceOptIn":{"type":"boolean","description":"Whether inference may leave the premises for a metered cloud provider."},"storeTranscriptsOptIn":{"type":"boolean","description":"Whether transcripts may be stored on the platform at all."},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["id","name","code","status","placeholder","cloudInferenceOptIn","storeTranscriptsOptIn","createdAt","updatedAt"],"additionalProperties":false},"WebhooksListItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"url":{"type":"string"},"events":{"type":"array","items":{"type":"string","enum":["device.online","device.offline","device.telemetry","command.result","thing.state","notification.raised","submission.state","webhook.ping"]}},"description":{"type":["string","null"]},"state":{"type":"string","enum":["ACTIVE","DISABLED"]},"failureCount":{"type":"integer"},"disabledAt":{"type":["string","null"],"format":"date-time"},"lastDeliveredAt":{"type":["string","null"],"format":"date-time"},"keyId":{"type":"string","format":"uuid"},"createdAt":{"type":"string","format":"date-time"}},"required":["id","url","events","description","state","failureCount","disabledAt","lastDeliveredAt","keyId","createdAt"],"additionalProperties":false}}},"security":[{"bearerAuth":[]}],"paths":{"/v1/auth/register":{"post":{"operationId":"authRegister","summary":"Create an account","tags":["Platform sessions (staff)"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthLoginResponse"}}}},"409":{"description":"An account with that email already exists"}},"x-alyt-service":"auth-service","description":"Passwords are hashed with argon2id. The same password rule is applied here and by reset-password, taken from one shared contract so a reset can never accept a weaker password than signup.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","format":"email"},"password":{"type":"string"},"name":{"type":"string","minLength":1}},"required":["email","password"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"auth-service.AuthController.register","x-alyt-scope":"session"}},"/v1/auth/login":{"post":{"operationId":"authLogin","summary":"Exchange credentials for a token pair","tags":["Platform sessions (staff)"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthLoginResponse"}}}},"401":{"description":"Invalid credentials, or a missing or wrong TOTP code"}},"x-alyt-service":"auth-service","description":"When TOTP is enabled on the account the `totp` field is required; omitting it is a 401, not a distinct status, so the response does not reveal whether MFA is on.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","format":"email"},"password":{"type":"string","minLength":8},"totp":{"type":"string","minLength":6,"maxLength":6}},"required":["email","password"],"additionalProperties":false}}}},"security":[],"x-alyt-handler":"auth-service.AuthController.login","x-alyt-scope":"session"}},"/v1/auth/refresh":{"post":{"operationId":"authRefresh","summary":"Rotate a refresh token","tags":["Platform sessions (staff)"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthLoginResponse"}}}},"401":{"description":"Unknown, expired, or already-used refresh token"}},"x-alyt-service":"auth-service","description":"Refresh tokens are single-use. Presenting one that has already been exchanged revokes the ENTIRE token family, not just that token, on the assumption it was stolen: the legitimate client is signed out and must log in again.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"refreshToken":{"type":"string","minLength":16}},"required":["refreshToken"],"additionalProperties":false}}}},"security":[],"x-alyt-handler":"auth-service.AuthController.refresh","x-alyt-scope":"session"}},"/v1/auth/logout":{"post":{"operationId":"authLogout","summary":"Revoke a refresh token family","tags":["Platform sessions (staff)"],"responses":{"204":{"description":"Success. Response body not described by the contract."}},"x-alyt-service":"auth-service","description":"Revokes every refresh token in the family the presented token belongs to, not just that token, so a rotated sibling on the same device does not outlive the sign-out. Access tokens already issued are not revoked and remain valid until they expire. An unknown token is a silent 204: there is nothing to revoke, and the answer must not reveal which tokens exist.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"refreshToken":{"type":"string","minLength":16}},"required":["refreshToken"],"additionalProperties":false}}}},"security":[],"x-alyt-handler":"auth-service.AuthController.logout","x-alyt-scope":"session"}},"/v1/auth/forgot-password":{"post":{"operationId":"authForgotPassword","summary":"Request a password-reset email","tags":["Platform sessions (staff)"],"responses":{"204":{"description":"Success. Response body not described by the contract."}},"x-alyt-service":"auth-service","description":"ALWAYS 204, whether or not the address has an account, with the same body and timing. A different answer for a known address would turn this into a way to ask whether someone has an ALYT account.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","format":"email"}},"required":["email"],"additionalProperties":false}}}},"security":[],"x-alyt-handler":"auth-service.AuthController.forgotPassword","x-alyt-scope":"session"}},"/v1/auth/reset-password":{"post":{"operationId":"authResetPassword","summary":"Set a new password using a reset token","tags":["Platform sessions (staff)"],"responses":{"204":{"description":"Success. Response body not described by the contract."},"400":{"description":"Unknown, expired or already-used reset token"}},"x-alyt-service":"auth-service","description":"Reset tokens are stored hashed and are single-use. A successful reset revokes ALL refresh-token families for the account, so every other session ends.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResetPasswordRequest"}}}},"security":[],"x-alyt-handler":"auth-service.AuthController.resetPassword","x-alyt-scope":"session"}},"/v1/auth/me":{"get":{"operationId":"authMe","summary":"The signed-in user, their memberships and their persona","tags":["Platform sessions (staff)"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthMeResponse"}}}},"401":{"description":"Missing or expired access token"}},"x-alyt-service":"auth-service","description":"`persona` decides which console a client should show. Memberships carry the tenantId explicitly, because every tenant-scoped endpoint requires one and there is deliberately no endpoint that turns a clientId into a tenantId.","security":[{"bearerAuth":[]}],"x-alyt-handler":"auth-service.AuthController.me","x-alyt-scope":"session"},"patch":{"operationId":"authUpdateMe","summary":"Change the signed-in user's preferences","tags":["Platform sessions (staff)"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthMeResponse"}}}},"401":{"description":"Missing or expired access token"}},"x-alyt-service":"auth-service","description":"Today the units: `metric` or `imperial`, read by the console and the phone wherever this person signs in. Merged into what was chosen before; answers the same as GET.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"preferences":{"type":"object","properties":{"units":{"type":["string","null"],"enum":["metric","imperial"]}},"additionalProperties":false}},"required":["preferences"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"auth-service.AuthController.updateMe","x-alyt-scope":"session"}},"/v1/auth/mfa/setup":{"post":{"operationId":"authSetupMfa","summary":"Begin TOTP enrolment and return the secret to display as a QR code","tags":["Platform sessions (staff)"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"secret":{"type":"string","description":"The base32 TOTP secret. Not active until /auth/mfa/enable confirms it."},"otpauthUrl":{"type":"string","description":"The otpauth:// URI to render as a QR code."}},"required":["secret","otpauthUrl"],"additionalProperties":false}}}},"401":{"description":"Missing or expired access token"},"409":{"description":"MFA is already enabled on this account"}},"x-alyt-service":"auth-service","description":"Enrolment is not complete until POST /auth/mfa/enable confirms a code.","security":[{"bearerAuth":[]}],"x-alyt-handler":"auth-service.AuthController.setupMfa","x-alyt-scope":"session"}},"/v1/auth/mfa/enable":{"post":{"operationId":"authEnableMfa","summary":"Confirm a TOTP code and turn MFA on","tags":["Platform sessions (staff)"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"mfaEnabled":{"const":true}},"required":["mfaEnabled"],"additionalProperties":false}}}},"400":{"description":"No enrolling secret: call /auth/mfa/setup first"},"401":{"description":"Missing or expired access token, or the code did not match the enrolling secret"},"409":{"description":"MFA is already enabled on this account"}},"x-alyt-service":"auth-service","description":"Requires a secret stored by an earlier POST /auth/mfa/setup on the same account, and the six-digit code an authenticator app derives from it; one time step of drift either side is accepted. On success the account is marked `mfaEnabled` and every later login must carry a `totp` field, or it is refused with a 401. Existing sessions are not ended.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"totp":{"type":"string","minLength":6,"maxLength":6}},"required":["totp"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"auth-service.AuthController.enableMfa","x-alyt-scope":"session"}},"/v1/developers/signup":{"post":{"operationId":"developersSignup","summary":"Create a developer account (self-service, instant)","tags":["Developer account"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"developerId":{"type":"string","format":"uuid"},"status":{"const":"PENDING_EMAIL"},"emailSent":{"type":"boolean"}},"required":["developerId","status","emailSent"],"additionalProperties":false}}}},"400":{"description":"The bot check failed, or the body is malformed"},"409":{"description":"An account with that email already exists"}},"x-alyt-service":"developer-service","description":"No approval: a developer evaluating at 2am is exactly who the portal is for. The bot check (Cloudflare Turnstile) runs first, the account is created by auth-service under the one password policy, and a verification email is sent. Nothing reaches a house, and no key exists until the email is verified. The terms version accepted is recorded per document.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","format":"email"},"password":{"type":"string"},"displayName":{"type":"string","minLength":1,"maxLength":120},"type":{"type":"string","enum":["INDIVIDUAL","BRAND","INTEGRATOR"],"default":"INDIVIDUAL"},"organisation":{"type":"string","minLength":1,"maxLength":200},"website":{"type":"string","format":"uri"},"turnstileToken":{"type":"string","minLength":1},"termsVersion":{"type":"string","minLength":1}},"required":["email","password","displayName","turnstileToken","termsVersion"],"additionalProperties":false}}}},"security":[],"x-alyt-handler":"developer-service.DevelopersController.signup","x-alyt-scope":"session"}},"/v1/developers/verify":{"post":{"operationId":"developersVerify","summary":"Verify the email, provision the sandbox, receive the first DEV key","tags":["Developer account"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"developerId":{"type":"string","format":"uuid"},"status":{"const":"ACTIVE"},"sandboxTenantId":{"type":"string","format":"uuid"},"sandbox":{"type":"object","properties":{"tenantCode":{"type":"string"},"clientId":{"type":"string","format":"uuid"},"propertyId":{"type":"string","format":"uuid"},"rooms":{"type":"array","items":{"type":"string"}},"virtualDevices":{"type":"integer"}},"required":["tenantCode","clientId","propertyId","rooms","virtualDevices"],"additionalProperties":false},"key":{"$ref":"#/components/schemas/DevelopersCreateKeyResponse"}},"required":["developerId","status","sandboxTenantId","sandbox","key"],"additionalProperties":false}}}},"400":{"description":"The link is unknown, used or expired"},"409":{"description":"The account is already verified"}},"x-alyt-service":"developer-service","description":"The link from the verification email, used once. Provisions a real tenant with a household, a home and four rooms, activates the account, and answers the first `alyt_dev_` key: shown here and never again. Virtual devices join the sandbox in a later phase.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"token":{"type":"string","minLength":16}},"required":["token"],"additionalProperties":false}}}},"security":[],"x-alyt-handler":"developer-service.DevelopersController.verify","x-alyt-scope":"session"}},"/v1/developers/me":{"get":{"operationId":"developersMe","summary":"The signed-in developer","tags":["Developer account"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DevelopersAdminListItem"}}}}},"x-alyt-service":"developer-service","description":"This operation returns the signed-in developer’s profile, status, verification details, and `sandboxTenantId`. Developers call it when they need the current account identity or state. It refuses calls without the `session` scope because there is no signed-in developer to return.","security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.DevelopersController.me","x-alyt-scope":"session"}},"/v1/developers/keys":{"get":{"operationId":"developersKeys","summary":"List the developer's API keys (never their secrets)","tags":["API keys and usage"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/DevelopersKeysItem"}},"nextCursor":{"type":"null"}},"required":["items","nextCursor"],"additionalProperties":false}}}}},"x-alyt-service":"developer-service","description":"This operation returns the signed-in developer’s key metadata in `items`, including `kind`, `prefix`, `scopes`, and lifecycle dates, but not secrets. Developers call it to review their DEV and LIVE keys; a DEV key acts in a sandbox, while a LIVE key acts only for its own connector. It refuses calls without the `session` scope because the key inventory belongs to the signed-in developer.","security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.DevelopersController.keys","x-alyt-scope":"session"},"post":{"operationId":"developersCreateKey","summary":"Create a DEV key, shown once","tags":["API keys and usage"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DevelopersCreateKeyResponse"}}}}},"x-alyt-service":"developer-service","description":"Scopes default to everything but `ai:chat`, which only a granted AI key carries. The secret is in this answer and nowhere else.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":80},"kind":{"type":"string","enum":["DEV","LIVE"],"default":"DEV"},"definitionId":{"type":"string","format":"uuid"},"scopes":{"type":"array","items":{"type":"string","enum":["tenant:read","rooms:write","devices:read","devices:write","devices:commands","telemetry:read","things:read","things:control","integrations:read","integrations:write","notifications:read","notifications:write","ai:chat","simulator:write","webhooks:write","events:read","firmware:read"]},"minItems":1},"expiresInDays":{"type":"integer","minimum":1,"maximum":3650}},"required":["name"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.DevelopersController.createKey","x-alyt-scope":"session"}},"/v1/developers/keys/{keyId}/rotate":{"post":{"operationId":"developersRotate","summary":"Rotate a key: a successor now, the predecessor for seven more days","tags":["API keys and usage"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DevelopersCreateKeyResponse"}}}},"404":{"description":"Not the developer's key"},"409":{"description":"A revoked key cannot be rotated"}},"x-alyt-service":"developer-service","description":"This operation creates a successor for the key identified by `keyId` and returns its metadata and `secret`. Developers call it when replacing a DEV or LIVE credential; the DEV successor acts in a sandbox, while the LIVE successor acts only for its own connector. It refuses calls without `keyId` or the `session` scope because rotation must target a key belonging to the signed-in developer.","parameters":[{"name":"keyId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.DevelopersController.rotate","x-alyt-scope":"session"}},"/v1/developers/keys/{keyId}/revoke":{"post":{"operationId":"developersRevoke","summary":"Revoke a key, instantly","tags":["API keys and usage"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DevelopersKeysItem"}}}},"404":{"description":"Not the developer's key"}},"x-alyt-service":"developer-service","description":"A first-class event: audited, and the gateway forgets the key on the next request rather than after a cache expiry.","parameters":[{"name":"keyId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string","minLength":1,"maxLength":200}},"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.DevelopersController.revoke","x-alyt-scope":"session"}},"/v1/developers/usage":{"get":{"operationId":"developersUsage","summary":"This month's usage per unit against the ceiling, and per key","tags":["API keys and usage"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"month":{"type":"string","pattern":"^\\d{4}-\\d{2}$"},"units":{"type":"array","items":{"type":"object","properties":{"unit":{"type":"string","enum":["API_CALLS","MQTT_MESSAGES","AI_TOKENS","AI_SECONDS","VIRTUAL_DEVICES","WEBHOOK_DELIVERIES"]},"used":{"type":"integer"},"ceiling":{"type":"integer"},"window":{"type":"string","enum":["day","month"]}},"required":["unit","used","ceiling","window"],"additionalProperties":false}},"byKey":{"type":"array","items":{"type":"object","properties":{"keyId":{"type":"string","format":"uuid"},"prefix":{"type":"string"},"unit":{"type":"string","enum":["API_CALLS","MQTT_MESSAGES","AI_TOKENS","AI_SECONDS","VIRTUAL_DEVICES","WEBHOOK_DELIVERIES"]},"used":{"type":"integer"}},"required":["keyId","prefix","unit","used"],"additionalProperties":false}}},"required":["month","units","byKey"],"additionalProperties":false}}}}},"x-alyt-service":"developer-service","description":"Returns usage for each `unit`, including `used`, `ceiling`, and `window`, with consumption attributed through `byKey`. Call it to monitor limits for the current month or the optional `month`. It refuses calls without the `session` scope because usage is tied to the authenticated developer.","parameters":[{"name":"month","in":"query","required":false,"schema":{"type":"string","pattern":"^\\d{4}-\\d{2}$"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.DevelopersController.usage","x-alyt-scope":"session"}},"/v1/developers/quota":{"get":{"operationId":"developersQuota","summary":"The developer's effective ceilings: the defaults with any override applied","tags":["API keys and usage"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/DevelopersAdminQuotaDefaultsValue"}}}}}},"x-alyt-service":"developer-service","description":"This operation returns the signed-in developer’s applied limits, with each unit reporting `ceiling`, `window`, and any `burstPerMinute`. Developers call it before planning usage against the current day or month limits. It refuses calls without the `session` scope because quotas are resolved for the signed-in developer.","security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.DevelopersController.quota","x-alyt-scope":"session"}},"/v1/developers/admin/quota-defaults":{"get":{"operationId":"developersAdminQuotaDefaults","summary":"The sandbox ceilings every developer starts with","tags":["Review (staff)"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/DevelopersAdminQuotaDefaultsValue"}}}}}},"x-alyt-service":"developer-service","description":"Returns the default sandbox quota entries, including `ceiling`, `window`, and optional `burstPerMinute`. Staff call it when they need to inspect the limits assigned to developer sandboxes; a household does not receive this staff-level configuration. It refuses requests without the `session` scope because the quota defaults require an authenticated session.","security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.DevelopersAdminController.quotaDefaults","x-alyt-scope":"session"},"patch":{"operationId":"developersAdminSetQuotaDefaults","summary":"Change the sandbox ceilings for everyone","tags":["Review (staff)"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/DevelopersAdminQuotaDefaultsValue"}}}}}},"x-alyt-service":"developer-service","description":"Nothing is hard-coded in a service: this row is what the gateway enforces, read with a short cache. Audited.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/DevelopersAdminQuotaDefaultsValue"}}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.DevelopersAdminController.setQuotaDefaults","x-alyt-scope":"session"}},"/v1/developers/admin/developers":{"get":{"operationId":"developersAdminList","summary":"Every developer account","tags":["Review (staff)"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/DevelopersAdminListItem"}},"nextCursor":{"type":"null"}},"required":["items","nextCursor"],"additionalProperties":false}}}}},"x-alyt-service":"developer-service","description":"Returns developer account records in `items`, including account identity, status, verification, MFA, sandbox, and creation details. Staff call it when they need to review developer accounts; a household does not receive this staff-level view. It refuses requests without the `session` scope because the account list requires an authenticated session.","security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.DevelopersAdminController.list","x-alyt-scope":"session"}},"/v1/developers/admin/developers/{developerId}/quota":{"put":{"operationId":"developersAdminSetQuota","summary":"Override one unit's ceiling for one developer, with a reason","tags":["Review (staff)"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"developerId":{"type":"string","format":"uuid"},"unit":{"type":"string"},"ceiling":{"type":"integer"},"window":{"type":"string","enum":["day","month"]},"overrideReason":{"type":["string","null"]}},"required":["developerId","unit","ceiling","window","overrideReason"],"additionalProperties":false}}}},"404":{"description":"Unknown developer"}},"x-alyt-service":"developer-service","description":"Staff call this operation to set a developer’s `ceiling` for one `unit` and record an `overrideReason`, with `window` defaulting to `month`. A household session sees less than staff and cannot use this administrative operation. It refuses requests missing `unit`, `ceiling`, or `overrideReason`, containing additional properties, or using values outside the defined enums and limits.","parameters":[{"name":"developerId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"unit":{"type":"string","enum":["API_CALLS","MQTT_MESSAGES","AI_TOKENS","AI_SECONDS","VIRTUAL_DEVICES","WEBHOOK_DELIVERIES"]},"ceiling":{"type":"integer","minimum":0},"window":{"type":"string","enum":["day","month"],"default":"month"},"overrideReason":{"type":"string","minLength":1,"maxLength":200}},"required":["unit","ceiling","overrideReason"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.DevelopersAdminController.setQuota","x-alyt-scope":"session"}},"/v1/developers/admin/invite":{"post":{"operationId":"developersAdminInvite","summary":"Invite a developer: the account exists, the verification link is answered to the inviter","tags":["Review (staff)"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"developerId":{"type":"string","format":"uuid"},"status":{"const":"PENDING_EMAIL"},"verificationUrl":{"type":"string","format":"uri"}},"required":["developerId","status","verificationUrl"],"additionalProperties":false}}}},"409":{"description":"An account with that email already exists"}},"x-alyt-service":"developer-service","description":"For a partner a member of staff is onboarding by hand. No bot check. The person sets their own password through the reset flow.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","format":"email"},"displayName":{"type":"string","minLength":1,"maxLength":120},"type":{"type":"string","enum":["INDIVIDUAL","BRAND","INTEGRATOR"],"default":"INDIVIDUAL"}},"required":["email","displayName"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.DevelopersAdminController.invite","x-alyt-scope":"session"}},"/v1/developers/auth/login":{"post":{"operationId":"developerAuthLogin","summary":"Sign in as a developer: an access token for developers.alyt.com and a rotating refresh token","tags":["Developer account"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeveloperAuthLoginResponse"}}}},"401":{"description":"Wrong email or password; or `totp required` when two-factor is on and no code was sent; or a wrong code"},"403":{"description":"Email not verified, or the account is suspended"}},"x-alyt-service":"developer-service","description":"A developer account is its own identity: the same address may own a home on app.alyt.com with a different password, and neither session opens the other site. A wrong email and a wrong password cost the same and answer the same 401. An account whose email is not verified yet is refused with 403 until the link is used.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","format":"email"},"password":{"type":"string","minLength":1,"maxLength":200},"totp":{"type":"string","minLength":6,"maxLength":12}},"required":["email","password"],"additionalProperties":false}}}},"security":[],"x-alyt-handler":"developer-service.DeveloperAuthController.login","x-alyt-scope":"session"}},"/v1/developers/auth/refresh":{"post":{"operationId":"developerAuthRefresh","summary":"Rotate the session: the presented refresh token is spent, a new pair is answered","tags":["Developer account"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeveloperAuthLoginResponse"}}}},"401":{"description":"The session has ended"}},"x-alyt-service":"developer-service","description":"Exchanges `refreshToken` for a new `accessToken` and `refreshToken`, with `expiresIn` and `kind` in the response. Call it when the current developer access token needs renewal. It refuses a request that omits `refreshToken`, provides fewer than 16 characters, or includes other properties because only a valid refresh request is accepted.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"refreshToken":{"type":"string","minLength":16}},"required":["refreshToken"],"additionalProperties":false}}}},"security":[],"x-alyt-handler":"developer-service.DeveloperAuthController.refresh","x-alyt-scope":"session"}},"/v1/developers/auth/logout":{"post":{"operationId":"developerAuthLogout","summary":"End the session: the refresh token stops working at once","tags":["Developer account"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"const":true}},"required":["ok"],"additionalProperties":false}}}}},"x-alyt-service":"developer-service","description":"Ends the developer session associated with `refreshToken`, making that token unusable immediately. Call it when a developer signs out or needs to revoke the current session. It refuses a request that omits `refreshToken`, provides fewer than 16 characters, or includes other properties because the request body must match the contract.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"refreshToken":{"type":"string","minLength":16}},"required":["refreshToken"],"additionalProperties":false}}}},"security":[],"x-alyt-handler":"developer-service.DeveloperAuthController.logout","x-alyt-scope":"session"}},"/v1/developers/auth/forgot-password":{"post":{"operationId":"developerAuthForgot","summary":"Mail a one-hour link to set a new password; the answer never says whether the address exists","tags":["Developer account"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"const":true}},"required":["ok"],"additionalProperties":false}}}}},"x-alyt-service":"developer-service","description":"Starts password recovery by sending a link that remains usable for one hour. Call it in the `session` authentication flow with `email` when a developer cannot sign in. It refuses an invalid `email` format, and `ok` does not reveal whether the address exists to prevent account discovery.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","format":"email"}},"required":["email"],"additionalProperties":false}}}},"security":[],"x-alyt-handler":"developer-service.DeveloperAuthController.forgot","x-alyt-scope":"session"}},"/v1/developers/auth/reset-password":{"post":{"operationId":"developerAuthReset","summary":"Set a new password from the mailed link; every session ends","tags":["Developer account"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"const":true}},"required":["ok"],"additionalProperties":false}}}},"400":{"description":"The link is unknown, used or expired"}},"x-alyt-service":"developer-service","description":"Uses `token` and `password` to change a developer password and terminate existing sessions. Call it after the developer follows the mailed password-reset link. It refuses a request that omits `token` or `password`, provides a `token` shorter than 16 characters, or includes other properties because the reset body must match the contract.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResetPasswordRequest"}}}},"security":[],"x-alyt-handler":"developer-service.DeveloperAuthController.reset","x-alyt-scope":"session"}},"/v1/developers/auth/mfa/setup":{"post":{"operationId":"developerAuthSetupMfa","summary":"Begin two-factor sign-in: a TOTP secret to put in an authenticator app, not active until confirmed","tags":["Developer account"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"secret":{"type":"string"},"otpauthUrl":{"type":"string"}},"required":["secret","otpauthUrl"],"additionalProperties":false}}}},"409":{"description":"Two-factor sign-in is already on"}},"x-alyt-service":"developer-service","description":"Answers a fresh base32 `secret` and the `otpauthUrl` the portal shows as a QR code. Nothing changes at sign-in until `/developers/auth/mfa/enable` proves the app holds the secret; calling setup again replaces an unconfirmed secret. Refused with 409 while two-factor is already on.","security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.DeveloperAuthController.setupMfa","x-alyt-scope":"session"}},"/v1/developers/auth/mfa/enable":{"post":{"operationId":"developerAuthEnableMfa","summary":"Turn two-factor sign-in on with a code from the app; the eight recovery codes are shown this once","tags":["Developer account"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"mfaEnabledAt":{"type":"string","format":"date-time"},"recoveryCodes":{"type":"array","items":{"type":"string"}}},"required":["mfaEnabledAt","recoveryCodes"],"additionalProperties":false}}}},"400":{"description":"Setup was not called first"},"401":{"description":"Wrong one-time code"},"409":{"description":"Already on"}},"x-alyt-service":"developer-service","description":"The six-digit `totp` must match the secret from setup. From then on sign-in needs a code or one unused recovery code; the codes are stored only as hashes and each works once. Refused with 401 on a wrong code, 400 without a setup first.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"totp":{"type":"string","pattern":"^[0-9]{6}$"}},"required":["totp"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.DeveloperAuthController.enableMfa","x-alyt-scope":"session"}},"/v1/developers/auth/mfa/disable":{"post":{"operationId":"developerAuthDisableMfa","summary":"Turn two-factor sign-in off with the password and a current code; other sessions end","tags":["Developer account"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"const":true}},"required":["ok"],"additionalProperties":false}}}},"401":{"description":"Wrong password or one-time code"},"409":{"description":"Not on"}},"x-alyt-service":"developer-service","description":"Disables two-factor sign-in for the signed-in developer and ends other sessions. Call it with the `session` scope and provide `password` and a current `totp` when the developer turns MFA off. It refuses missing or invalid credentials because both the password and current code are required to authorize the change.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"password":{"type":"string","minLength":1,"maxLength":200},"totp":{"type":"string","minLength":6,"maxLength":12}},"required":["password","totp"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.DeveloperAuthController.disableMfa","x-alyt-scope":"session"}},"/v1/developers/auth/change-password":{"post":{"operationId":"developerAuthChange","summary":"Change the password of the signed-in developer; other sessions end","tags":["Developer account"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"const":true}},"required":["ok"],"additionalProperties":false}}}},"401":{"description":"The current password is wrong"}},"x-alyt-service":"developer-service","description":"Updates the signed-in developer’s password and ends other sessions. Call it with the `session` scope and provide `currentPassword` and `newPassword` when the developer chooses a new password. It refuses a missing or invalid `currentPassword`, or a request missing `newPassword`, because the change must be authorized and both fields are required.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"currentPassword":{"type":"string","minLength":1,"maxLength":200},"newPassword":{"type":"string"}},"required":["currentPassword","newPassword"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.DeveloperAuthController.change","x-alyt-scope":"session"}},"/v1/developers/submissions":{"get":{"operationId":"submissionsList","summary":"The developer's connector versions, newest first","tags":["Submissions"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/SubmissionsListItem"}},"nextCursor":{"type":"null"}},"required":["items","nextCursor"],"additionalProperties":false}}}}},"x-alyt-service":"developer-service","description":"A developer calls this session-scoped operation to inspect their connector versions through `items`, including each version's `state`, `checklist`, and `probe`. It refuses access to other developers' submissions because a developer session sees less than staff, and it accepts no request payload or parameters.","security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.SubmissionsController.list","x-alyt-scope":"session"},"post":{"operationId":"submissionsCreate","summary":"Create a draft connector version; the runtime token is in this answer and nowhere else","tags":["Submissions"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"definitionId":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"semver":{"type":"string"},"state":{"type":"string","enum":["DRAFT","IN_REVIEW","CHANGES_REQUESTED","APPROVED","PUBLISHED","DEPRECATED","REJECTED"]},"distribution":{"type":"string","enum":["PUBLIC","PRIVATE"]},"descriptor":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptor"},"prose":{"type":"string"},"runtime":{"$ref":"#/components/schemas/ReviewQueueItemRuntime"},"aiDeclaration":{"type":"string","enum":["none","alyt","own_cloud","own_local"]},"changelog":{"type":"string"},"checklist":{"type":["array","null"],"items":{"$ref":"#/components/schemas/ReviewQueueItemChecklistItem"}},"probe":{"type":["array","null"],"items":{"$ref":"#/components/schemas/ReviewQueueItemProbeItem"}},"reviewNotes":{"type":"array","items":{"$ref":"#/components/schemas/ReviewQueueItemReviewNotesItem"}},"hasTestInput":{"type":"boolean"},"submittedAt":{"type":["string","null"],"format":"date-time"},"reviewedAt":{"type":["string","null"],"format":"date-time"},"publishedAt":{"type":["string","null"],"format":"date-time"},"deprecatedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"runtimeToken":{"type":["string","null"]}},"required":["id","definitionId","slug","name","semver","state","distribution","descriptor","prose","runtime","aiDeclaration","changelog","checklist","probe","reviewNotes","hasTestInput","submittedAt","reviewedAt","publishedAt","deprecatedAt","createdAt","updatedAt","runtimeToken"],"additionalProperties":false}}}},"400":{"description":"The descriptor's kind is not the slug in upper case, or the body is malformed"},"409":{"description":"The slug is someone else's, a version of it is still open, or that semver exists"}},"x-alyt-service":"developer-service","description":"The slug names the definition: a new one, or a later version of the developer's own. The descriptor is THE integration descriptor the clients render from, validated here; the prose is the page a person reads; the runtime is the developer's own HTTPS server speaking the four routes. One runtime token per definition, minted with the first version: the platform presents it as `Authorization: Bearer` on every call, and the runtime must refuse any other. Test values for the connect fields are stored for the probe and never returned.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"slug":{"type":"string","pattern":"^[a-z][a-z0-9_]{2,39}$"},"name":{"type":"string","minLength":1,"maxLength":80},"descriptor":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptor"},"prose":{"type":"string","maxLength":60000,"default":""},"runtime":{"$ref":"#/components/schemas/SubmissionsCreateRuntime"},"aiDeclaration":{"type":"string","enum":["none","alyt","own_cloud","own_local"],"default":"none"},"testInput":{"type":"object","additionalProperties":{"type":"string"}},"changelog":{"type":"string","maxLength":4000,"default":""}},"required":["slug","name","descriptor","runtime"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.SubmissionsController.create","x-alyt-scope":"session"}},"/v1/developers/submissions/{versionId}":{"get":{"operationId":"submissionsGet","summary":"One of the developer's versions, with its last checks and probe","tags":["Submissions"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmissionsListItem"}}}},"404":{"description":"Not one of the developer's"}},"x-alyt-service":"developer-service","description":"A developer calls this session-scoped operation to inspect the version identified by `versionId`, including its `checklist`, `probe`, and `reviewNotes`. It refuses access to a version outside that developer's submissions because a developer session sees less than staff.","parameters":[{"name":"versionId","in":"path","required":true,"schema":{"type":"string"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.SubmissionsController.get","x-alyt-scope":"session"},"patch":{"operationId":"submissionsUpdate","summary":"Edit a draft (or a version sent back with changes requested); an edit clears the last checks","tags":["Submissions"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmissionsListItem"}}}},"409":{"description":"The version is frozen (in review, approved, published, deprecated or rejected)"}},"x-alyt-service":"developer-service","description":"Updates `name`, `descriptor`, `prose`, `runtime`, `aiDeclaration`, `testInput`, or `changelog` for the submission version identified by `versionId`, and clears its previous checks. Call it while the version is in `DRAFT` or `CHANGES_REQUESTED`. It refuses versions in any other `state` because they are not editable, and it requires the `session` scope.","parameters":[{"name":"versionId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":80},"descriptor":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptor"},"prose":{"type":"string","maxLength":60000,"default":""},"runtime":{"$ref":"#/components/schemas/SubmissionsCreateRuntime"},"aiDeclaration":{"type":"string","enum":["none","alyt","own_cloud","own_local"],"default":"none"},"testInput":{"type":"object","additionalProperties":{"type":"string"}},"changelog":{"type":"string","maxLength":4000,"default":""}},"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.SubmissionsController.update","x-alyt-scope":"session"}},"/v1/developers/submissions/{versionId}/submit":{"post":{"operationId":"submissionsSubmit","summary":"Run the mechanical checks and the probe; enter review only if everything passed","tags":["Submissions"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"state":{"type":"string","enum":["DRAFT","IN_REVIEW","CHANGES_REQUESTED","APPROVED","PUBLISHED","DEPRECATED","REJECTED"]},"checklist":{"type":"array","items":{"$ref":"#/components/schemas/ReviewQueueItemChecklistItem"}},"probe":{"type":"array","items":{"$ref":"#/components/schemas/ReviewQueueItemProbeItem"}},"submitted":{"type":"boolean"}},"required":["state","checklist","probe","submitted"],"additionalProperties":false}}}},"409":{"description":"The version is not a draft"}},"x-alyt-service":"developer-service","description":"The checks are what a machine can decide: the descriptor validates, the kind is the slug, what it brings is known, the connect method is closed-set, the disconnect consequence is stated, no credential is in the text, the runtime is a public HTTPS address, and it is a cloud connector (local ones wait for the WASM runtime). Then the probe calls the runtime exactly as tenant-service will, with the runtime token: health, connect with the test values, discover, one state read, one control. Both transcripts are answered and stored either way; `submitted` is true only when every step passed, and the version is then frozen in review.","parameters":[{"name":"versionId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{},"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.SubmissionsController.submit","x-alyt-scope":"session"}},"/v1/developers/admin/submissions":{"get":{"operationId":"reviewQueue","summary":"The review queue: waiting first, then everything that is not a draft","tags":["Review (staff)"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/ReviewQueueItem"}},"nextCursor":{"type":"null"}},"required":["items","nextCursor"],"additionalProperties":false}}}}},"x-alyt-service":"developer-service","description":"Returns submission `items` for staff review, ordered with `IN_REVIEW` entries first and excluding `DRAFT` entries. Call it when staff need submission, developer, test, and prior-version details; it refuses household users and requests without a `session` because the administrative queue exposes more information than household access permits.","security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.ReviewController.queue","x-alyt-scope":"session"}},"/v1/developers/admin/submissions/{versionId}":{"get":{"operationId":"reviewDetail","summary":"One submission with its checks, its probe transcript, and the previous published version for the diff","tags":["Review (staff)"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewQueueItem"}}}}},"x-alyt-service":"developer-service","description":"Returns the submission identified by `versionId`, including its `checklist`, `probe`, `reviewNotes`, and `previous` version. Staff call it when examining the evidence and differences before changing the submission's `state`. It refuses household sessions because the administrative record, including developer details, is visible only to staff.","parameters":[{"name":"versionId","in":"path","required":true,"schema":{"type":"string"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.ReviewController.detail","x-alyt-scope":"session"}},"/v1/developers/admin/submissions/{versionId}/request-changes":{"post":{"operationId":"reviewRequestChanges","summary":"Send it back with a reason the developer reads","tags":["Review (staff)"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewQueueItem"}}}},"409":{"description":"Not in review"}},"x-alyt-service":"developer-service","description":"Staff call this session-scoped operation when the submission identified by `versionId` needs revisions, setting its `state` to `CHANGES_REQUESTED` and retaining `text` in `reviewNotes`. It refuses empty `text`, `text` longer than 4000 characters, and additional request fields because the developer must receive a bounded review reason.","parameters":[{"name":"versionId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"text":{"type":"string","minLength":1,"maxLength":4000}},"required":["text"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.ReviewController.requestChanges","x-alyt-scope":"session"}},"/v1/developers/admin/submissions/{versionId}/reject":{"post":{"operationId":"reviewReject","summary":"Reject with a reason; not a delete, a resubmission should know what was said","tags":["Review (staff)"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewQueueItem"}}}},"409":{"description":"Not in review"}},"x-alyt-service":"developer-service","description":"Staff call this session-scoped operation to set the submission identified by `versionId` to `REJECTED` and retain `text` in `reviewNotes` for a future resubmission. It does not delete the submission. It refuses empty `text`, `text` longer than 4000 characters, and additional request fields because the rejection must contain only a bounded review reason.","parameters":[{"name":"versionId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"text":{"type":"string","minLength":1,"maxLength":4000}},"required":["text"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.ReviewController.reject","x-alyt-scope":"session"}},"/v1/developers/admin/submissions/{versionId}/approve":{"post":{"operationId":"reviewApprove","summary":"Judge it fit; not yet installable","tags":["Review (staff)"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewQueueItem"}}}},"409":{"description":"Not in review"}},"x-alyt-service":"developer-service","description":"Marks the submission identified by `versionId` as `APPROVED` and records the required review `text`; it does not change the state to `PUBLISHED`. Staff call it after the submission's evidence supports approval. It refuses household sessions because administrative review is limited to staff. It also refuses `text` that is empty or longer than 4000 characters because a review note must meet those bounds.","parameters":[{"name":"versionId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"text":{"type":"string","minLength":1,"maxLength":4000}},"required":["text"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.ReviewController.approve","x-alyt-scope":"session"}},"/v1/developers/admin/submissions/{versionId}/publish":{"post":{"operationId":"reviewPublish","summary":"Put it in the catalogue: PUBLIC, installable by any tenant, a LIVE key may be issued for it","tags":["Review (staff)"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewQueueItem"}}}},"409":{"description":"Not approved"}},"x-alyt-service":"developer-service","description":"The previously current version is deprecated for new installs only; every house running it keeps running it.","parameters":[{"name":"versionId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"text":{"type":"string","minLength":1,"maxLength":4000}},"required":["text"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.ReviewController.publish","x-alyt-scope":"session"}},"/v1/developers/admin/submissions/{versionId}/deprecate":{"post":{"operationId":"reviewDeprecate","summary":"No new installs; existing ones keep working","tags":["Review (staff)"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewQueueItem"}}}},"409":{"description":"Not published"}},"x-alyt-service":"developer-service","description":"Marks the submission identified by `versionId` as `DEPRECATED` and records the required `text`. Staff call it when a connector version should no longer be available for new installations, while existing installed versions continue operating. It refuses household sessions because administrative review is limited to staff. It also refuses `text` that is empty or longer than 4000 characters because a deprecation note must meet those bounds.","parameters":[{"name":"versionId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"text":{"type":"string","minLength":1,"maxLength":4000}},"required":["text"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.ReviewController.deprecate","x-alyt-scope":"session"}},"/v1/developers/admin/developers/{developerId}/verify":{"post":{"operationId":"reviewVerify","summary":"Grant or remove the verified badge: identity, never quality","tags":["Review (staff)"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"developerId":{"type":"string","format":"uuid"},"verifiedAt":{"type":["string","null"],"format":"date-time"}},"required":["developerId","verifiedAt"],"additionalProperties":false}}}}},"x-alyt-service":"developer-service","description":"Staff call this session-scoped operation to grant or remove identity verification for `developerId` by setting `verified`; omitting `verified` defaults it to true. It refuses non-boolean `verified` values and additional request fields because verification records identity, not connector quality.","parameters":[{"name":"developerId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"verified":{"type":"boolean","default":true}},"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.ReviewController.verify","x-alyt-scope":"session"}},"/v1/catalogue/definitions":{"get":{"operationId":"catalogueDefinitions","summary":"Every published community connector: descriptor, page, author, AI declaration","tags":["Catalogue"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/CatalogueDefinitionsItem"}},"nextCursor":{"type":"null"}},"required":["items","nextCursor"],"additionalProperties":false}}}}},"x-alyt-service":"developer-service","description":"Returns `items` containing the descriptor, prose, author, and AI declaration for published community connectors. Call it without a scope when building or refreshing a catalogue view. It does not expose unpublished connectors because this catalogue contains published definitions only.","security":[],"x-alyt-handler":"developer-service.CatalogueController.definitions","x-alyt-scope":null}},"/v1/catalogue/definitions/{slug}":{"get":{"operationId":"catalogueOne","summary":"One published community connector by slug","tags":["Catalogue"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CatalogueDefinitionsItem"}}}},"404":{"description":"Not published"}},"x-alyt-service":"developer-service","description":"Returns the definition for the published community connector identified by `slug`. Call it without a scope when displaying or inspecting a selected connector. It refuses a `slug` that does not identify a published connector because unknown or unpublished definitions are not exposed.","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"security":[],"x-alyt-handler":"developer-service.CatalogueController.one","x-alyt-scope":null}},"/v1/developers/webhooks":{"get":{"operationId":"webhooksList","summary":"The developer's webhook endpoints","tags":["Events and webhooks"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/WebhooksListItem"}},"nextCursor":{"type":"null"}},"required":["items","nextCursor"],"additionalProperties":false}}}}},"x-alyt-service":"developer-service","description":"Returns the caller’s webhook endpoints with their `url`, `events`, `state`, `failureCount`, and delivery timestamps. Call it when displaying or managing the endpoints associated with the developer. It refuses callers without `webhooks:write` because that scope is required.","security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.WebhooksController.list","x-alyt-scope":"webhooks:write"},"post":{"operationId":"webhooksCreate","summary":"Register an endpoint; the signing secret is in this answer and nowhere else","tags":["Events and webhooks"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"url":{"type":"string"},"events":{"type":"array","items":{"type":"string","enum":["device.online","device.offline","device.telemetry","command.result","thing.state","notification.raised","submission.state","webhook.ping"]}},"description":{"type":["string","null"]},"state":{"type":"string","enum":["ACTIVE","DISABLED"]},"failureCount":{"type":"integer"},"disabledAt":{"type":["string","null"],"format":"date-time"},"lastDeliveredAt":{"type":["string","null"],"format":"date-time"},"keyId":{"type":"string","format":"uuid"},"createdAt":{"type":"string","format":"date-time"},"secret":{"type":"string"}},"required":["id","url","events","description","state","failureCount","disabledAt","lastDeliveredAt","keyId","createdAt","secret"],"additionalProperties":false}}}},"403":{"description":"No active key to bind the endpoint to"},"409":{"description":"Ten active endpoints already"}},"x-alyt-service":"developer-service","description":"An endpoint belongs to one of the developer's keys, which is what it hears: a DEV key's endpoint receives its sandbox tenant's events, a LIVE key's the events of every tenant that installed its connector. Deliveries are POSTed as JSON with `X-ALYT-Signature: t=<seconds>,v1=<hex hmac-sha256(secret, t.body)>`, retried on a doubling schedule for a day, and the endpoint is disabled after fifty consecutive failures. Ten active endpoints at most.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri","maxLength":500},"events":{"type":"array","items":{"type":"string","enum":["device.online","device.offline","device.telemetry","command.result","thing.state","notification.raised","submission.state","webhook.ping"]},"minItems":1,"maxItems":16},"description":{"type":"string","maxLength":200},"keyId":{"type":"string","format":"uuid"}},"required":["url","events"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.WebhooksController.create","x-alyt-scope":"webhooks:write"}},"/v1/developers/webhooks/{id}":{"delete":{"operationId":"webhooksRemove","summary":"Remove an endpoint and its delivery history","tags":["Events and webhooks"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"const":true}},"required":["ok"],"additionalProperties":false}}}},"404":{"description":"Not the developer's endpoint"}},"x-alyt-service":"developer-service","description":"Deletes the webhook identified by `id` and returns `ok` when removal succeeds. Call it when an endpoint should no longer receive deliveries; it refuses requests without `webhooks:write` because deletion changes webhook configuration and delivery history.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.WebhooksController.remove","x-alyt-scope":"webhooks:write"}},"/v1/developers/webhooks/{id}/enable":{"post":{"operationId":"webhooksEnable","summary":"Re-enable a disabled endpoint; its failure count starts again","tags":["Events and webhooks"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhooksListItem"}}}},"404":{"description":"Not the developer's endpoint"}},"x-alyt-service":"developer-service","description":"Changes the webhook identified by `id` to `ACTIVE` and restarts its `failureCount`. Call it when an endpoint is `DISABLED` and ready to receive its configured `events` again. It refuses callers without `webhooks:write` because that scope is required.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.WebhooksController.enable","x-alyt-scope":"webhooks:write"}},"/v1/developers/webhooks/{id}/test":{"post":{"operationId":"webhooksTest","summary":"Queue a webhook.ping delivery to this endpoint","tags":["Events and webhooks"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"queued":{"const":true}},"required":["queued"],"additionalProperties":false}}}},"404":{"description":"Not the developer's endpoint"}},"x-alyt-service":"developer-service","description":"This operation checks the webhook identified by `id` by scheduling a ping and returns `queued` as true when accepted. Call it when validating that the endpoint can receive webhook deliveries. It refuses calls without `webhooks:write` because testing a webhook requires write access.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.WebhooksController.test","x-alyt-scope":"webhooks:write"}},"/v1/developers/webhooks/{id}/deliveries":{"get":{"operationId":"webhooksDeliveries","summary":"The last fifty deliveries: status, attempts, response code, the signed payload","tags":["Events and webhooks"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"eventId":{"type":"string"},"eventType":{"type":"string","enum":["device.online","device.offline","device.telemetry","command.result","thing.state","notification.raised","submission.state","webhook.ping"]},"status":{"type":"string","enum":["pending","delivered","failed","given-up"]},"attempt":{"type":"integer"},"responseCode":{"type":["integer","null"]},"nextAt":{"type":["string","null"],"format":"date-time"},"deliveredAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":"string","format":"date-time"},"payload":{"$ref":"#/components/schemas/StreamStreamResponse"}},"required":["id","eventId","eventType","status","attempt","responseCode","nextAt","deliveredAt","createdAt","payload"],"additionalProperties":false}},"nextCursor":{"type":"null"}},"required":["items","nextCursor"],"additionalProperties":false}}}},"404":{"description":"Not the developer's endpoint"}},"x-alyt-service":"developer-service","description":"Returns up to fifty recent delivery records for the webhook identified by `id`, including `status`, `attempt`, `responseCode`, and `payload`. Call it when diagnosing webhook delivery or inspecting the event data sent to an endpoint. It refuses callers without `webhooks:write` because that scope is required.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.WebhooksController.deliveries","x-alyt-scope":"webhooks:write"}},"/v1/developers/events/stream":{"get":{"operationId":"streamStream","summary":"The event stream: server-sent events for the caller's tenants, resumable with after=<id>","tags":["Events and webhooks"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StreamStreamResponse"}}}}},"x-alyt-service":"developer-service","description":"`text/event-stream`. Each event is `event: <kind>`, `id: <outbox id>`, `data: <envelope>`; a comment every 15 seconds keeps the connection alive. `events` filters kinds (comma-separated); `after` resumes from an id. A key hears its tenants; a session hears its sandbox. The outbox keeps a day, so a client away longer starts from now.","parameters":[{"name":"events","in":"query","required":false,"schema":{"type":"string"}},{"name":"after","in":"query","required":false,"schema":{"type":"string","pattern":"^\\d+$"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"developer-service.StreamController.stream","x-alyt-scope":"events:read"}},"/v1/developers/simulator/devices":{"get":{"operationId":"simulatorList","summary":"The sandbox's virtual devices, with their scenario","tags":["Simulator"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/SimulatorListItem"}},"nextCursor":{"type":"null"},"ceiling":{"type":"integer"}},"required":["items","nextCursor","ceiling"],"additionalProperties":false}}}}},"x-alyt-service":"device-simulator","description":"Each is a real Device row on the real broker; `connected` says whether the simulator holds its session right now. `ceiling` is the sandbox's VIRTUAL_DEVICES quota.","parameters":[{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-simulator.SimulatorController.list","x-alyt-scope":"simulator:write"},"post":{"operationId":"simulatorCreate","summary":"Create a virtual device in the sandbox","tags":["Simulator"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimulatorListItem"}}}},"400":{"description":"At the ceiling, or malformed"},"403":{"description":"Not a sandbox tenant, or not its ADMIN"}},"x-alyt-service":"device-simulator","description":"A pairing token is minted as the caller, the device registers through POST /devices/register with a serial in the X band (model ALSIMH, ALSIMS, ALSIML, ALSIMN or ALSIMT by profile), receives real broker credentials, and starts announcing itself: availability, status, telemetry, and answers to commands. Refused for any tenant that is not a sandbox, and at the ceiling.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"tenantId":{"type":"string","format":"uuid"},"propertyId":{"type":"string","format":"uuid"},"roomId":{"type":"string","format":"uuid"},"profile":{"type":"string","enum":["HUB","SPEAKER","LIGHT","SENSOR","THERMOSTAT"]},"name":{"type":"string","minLength":1,"maxLength":80}},"required":["tenantId","propertyId","profile"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-simulator.SimulatorController.create","x-alyt-scope":"simulator:write"}},"/v1/developers/simulator/devices/{id}/scenario":{"post":{"operationId":"simulatorScenario","summary":"Run a scenario on a virtual device","tags":["Simulator"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimulatorListItem"}}}},"404":{"description":"Not a virtual device of this sandbox"}},"x-alyt-service":"device-simulator","description":"NORMAL: heartbeat and telemetry on schedule. OFFLINE: stops every message but holds the socket open, so liveness derives the state after the grace window. LOW_BATTERY: battery telemetry falls under the alert threshold. SENSOR_ERROR: a fault on the status channel, and the failed metric stops. BURST: twenty telemetry messages a second, bounded by the MQTT quota. FLAPPING: disconnects and reconnects every thirty seconds, so the last will fires each time. SLOW: answers a command after five seconds, so a pending state is exercised. With `seconds` the device reverts to NORMAL afterwards.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"tenantId":{"type":"string","format":"uuid"},"scenario":{"type":"string","enum":["NORMAL","OFFLINE","LOW_BATTERY","SENSOR_ERROR","BURST","FLAPPING","SLOW"]},"seconds":{"type":"integer","minimum":1,"maximum":86400}},"required":["tenantId","scenario"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-simulator.SimulatorController.scenario","x-alyt-scope":"simulator:write"}},"/v1/developers/simulator/devices/{id}":{"delete":{"operationId":"simulatorRemove","summary":"Remove a virtual device","tags":["Simulator"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"const":true}},"required":["deleted"],"additionalProperties":false}}}},"404":{"description":"Not a virtual device of this sandbox"}},"x-alyt-service":"device-simulator","description":"Its session ends with the offline will, and the Device row is soft-deleted through device-service as the caller.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-simulator.SimulatorController.remove","x-alyt-scope":"simulator:write"}},"/v1/devices/register":{"post":{"operationId":"registrationRegister","summary":"Register a device and receive its MQTT credentials","tags":["Devices"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"deviceId":{"type":"string","format":"uuid"},"tenantId":{"type":"string","format":"uuid"},"mqtt":{"type":"object","properties":{"url":{"type":"string"},"username":{"type":"string"},"password":{"type":"string"},"clientId":{"type":"string"}},"required":["url","username","password","clientId"],"additionalProperties":false}},"required":["deviceId","tenantId","mqtt"],"additionalProperties":false}}}},"400":{"description":"Malformed serial, or a pairing token that is unknown, expired or already redeemed"}},"x-alyt-service":"device-service","description":"The one call hardware makes before it has any credentials, so it takes no bearer token: the pairing token IS the authorisation. The serial's 3-character tenant code decides ownership — a known code associates the device with that tenant, and an unknown one creates a placeholder tenant so the device still reports telemetry from day one. Credentials are returned here and never shipped in an image.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"serial":{"type":"string","pattern":"^([A-Z0-9]{6})-([A-Z0-9]{2})-([A-Z0-9]{3})-([A-Z]{2})-(\\d{4})-(\\d{2})-(\\d{4})$"},"pairingToken":{"type":"string","minLength":16},"firmware":{"type":"string"},"macAddress":{"type":"string"}},"required":["serial","pairingToken"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.RegistrationController.register","x-alyt-scope":"devices:write"}},"/v1/devices/fleet/summary":{"get":{"operationId":"fleetSummary","summary":"Device counts by status for a tenant, with the offline ones named","tags":["Devices"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"tenantId":{"type":"string","format":"uuid"},"devices":{"type":"integer","description":"Hardware only. Agents are counted apart, below."},"online":{"type":"integer"},"offline":{"type":"integer"},"provisioning":{"type":"integer"},"disabled":{"type":"integer"},"awaitingReplacement":{"type":"integer"},"properties":{"type":"integer"},"agents":{"type":"integer","description":"Per-property edge agents: software, never a box in a room."},"agentsOnline":{"type":"integer"},"offlineDevices":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"serial":{"type":["string","null"]},"roomName":{"type":["string","null"]},"lastSeen":{"type":["string","null"],"format":"date-time"}},"required":["id","name","serial","roomName","lastSeen"],"additionalProperties":false},"description":"Capped at 25, longest-silent first."}},"required":["tenantId","devices","online","offline","provisioning","disabled","awaitingReplacement","properties","agents","agentsOnline","offlineDevices"],"additionalProperties":false}}}},"403":{"description":"The caller has no access to that tenant"}},"x-alyt-service":"device-service","description":"Replaces a console panel that fanned out over GET /tenants and GET /devices and counted client-side. The offline list is capped so a large fleet cannot be made to stream every row into a browser.","parameters":[{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.FleetController.summary","x-alyt-scope":"devices:read"}},"/v1/devices/registrations":{"get":{"operationId":"fleetRegistrations","summary":"Recent device registrations across every tenant, newest first","tags":["Devices"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"The unit's id."},"deviceId":{"type":"string","format":"uuid"},"serial":{"type":"string"},"tenantCode":{"type":"string","minLength":3,"maxLength":3},"tenantName":{"type":"string"},"placeholder":{"type":"boolean","description":"True when the tenant was created from an unrecognised serial code."},"at":{"type":"string","format":"date-time","description":"When the unit was installed into its device."}},"required":["id","deviceId","serial","tenantCode","tenantName","placeholder","at"],"additionalProperties":false}},"nextCursor":{"type":["string","null"]}},"required":["items","nextCursor"],"additionalProperties":false}}}},"403":{"description":"Platform staff only"}},"x-alyt-service":"device-service","description":"The platform operator's feed, and the only place a PLACEHOLDER tenant becomes visible: an unrecognised tenant code in a serial still admits the device and collects its telemetry, so a brand appears here before anyone has spoken to them. Platform staff only — it deliberately crosses tenant boundaries.","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string","maxLength":200}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.FleetController.registrations","x-alyt-scope":"devices:read"}},"/v1/devices/alerts":{"get":{"operationId":"fleetDeviceAlerts","summary":"What is wrong in this tenant right now","tags":["Devices"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Derived from the condition, e.g. `offline:<deviceId>`, so it is stable across polls."},"severity":{"type":"string","enum":["warn","bad"]},"title":{"type":"string"},"detail":{"type":"string"},"deviceId":{"type":"string","format":"uuid"},"at":{"type":"string","format":"date-time"}},"required":["id","severity","title","detail","deviceId","at"],"additionalProperties":false}},"nextCursor":{"type":["string","null"]}},"required":["items","nextCursor"],"additionalProperties":false}}}},"403":{"description":"The caller has no access to that tenant"}},"x-alyt-service":"device-service","description":"DERIVED from current state and never stored, which is what makes alerts auto-expire: when the condition ends the alert simply stops being returned, so there is no resolver to forget. Ids are derived from the condition too, so the same problem keeps the same id across polls and a client can tell 'still this' from 'a new one'. A device that has merely missed a heartbeat is reported as such, never as offline.","parameters":[{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string","maxLength":200}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.FleetController.deviceAlerts","x-alyt-scope":"devices:read"}},"/v1/devices/onboarding-events":{"post":{"operationId":"fleetOnboardingEvent","summary":"Record one step of the add-a-device flow, for the funnel","tags":["Devices"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"recorded":{"const":true},"step":{"type":"string","enum":["room-chosen","token-minted","device-found","network-listed","credentials-sent","device-registered","abandoned"]}},"required":["recorded","step"],"additionalProperties":false}}}},"403":{"description":"The caller has no access to that tenant"}},"x-alyt-service":"device-service","description":"Written to the audit log, so drop-off is measurable without a second store. No vendor publishes setup-abandonment figures and no peer-reviewed study of it appears to exist, so measuring OUR flow is the only way to know where it fails. Deliberately carries no free text: a step name from a fixed list, and the ids already in the URL. A funnel that collects what someone typed into a WiFi form would be a privacy problem, not a metric.","parameters":[{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"step":{"type":"string","enum":["room-chosen","token-minted","device-found","network-listed","credentials-sent","device-registered","abandoned"]},"attemptId":{"type":"string","format":"uuid"},"roomId":{"type":"string","format":"uuid"}},"required":["step","attemptId"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.FleetController.onboardingEvent","x-alyt-scope":"devices:write"}},"/v1/devices/onboarding-funnel":{"get":{"operationId":"fleetOnboardingFunnel","summary":"How far people get in the add-a-device flow, and where they stop","tags":["Devices"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"windowDays":{"type":"integer"},"since":{"type":"string","format":"date-time"},"steps":{"type":"array","items":{"type":"object","properties":{"step":{"type":"string","enum":["room-chosen","token-minted","device-found","network-listed","credentials-sent","device-registered"]},"attempts":{"type":"integer","description":"Distinct attempts that reached this step."},"droppedFromPrevious":{"type":["integer","null"]}},"required":["step","attempts","droppedFromPrevious"],"additionalProperties":false}},"abandoned":{"type":"integer"},"note":{"type":"string"}},"required":["windowDays","since","steps","abandoned"],"additionalProperties":false}}}},"403":{"description":"The caller has no access to that tenant"}},"x-alyt-service":"device-service","description":"Counts per step from the audit log, newest window first. The drop-off between two adjacent steps is the number that matters.","parameters":[{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}},{"name":"days","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":90,"default":30}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.FleetController.onboardingFunnel","x-alyt-scope":"devices:read"}},"/v1/devices/commissioning":{"get":{"operationId":"fleetCommissioning","summary":"Devices in a tenant that are registered but not yet finished","tags":["Devices"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"serial":{"type":["string","null"]},"name":{"type":"string"},"propertyName":{"type":["string","null"]},"roomName":{"type":["string","null"]},"step":{"type":"string","enum":["awaiting-power","awaiting-room","verify-audio"]},"since":{"type":"string","format":"date-time","description":"The device's last change."}},"required":["id","serial","name","propertyName","roomName","step","since"],"additionalProperties":false}},"nextCursor":{"type":["string","null"]}},"required":["items","nextCursor"],"additionalProperties":false}}}},"403":{"description":"The caller has no access to that tenant"}},"x-alyt-service":"device-service","description":"The installer queue. The step is DERIVED from what the registry knows — never seen, seen but in no room, in a room with no speaker — rather than stored, because a stored step is a second source of truth that drifts from the device's real state. A device that is online, in a room, with a speaker in it does not appear here at all.","parameters":[{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string","maxLength":200}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.FleetController.commissioning","x-alyt-scope":"devices:read"}},"/v1/devices/rollouts":{"get":{"operationId":"rolloutList","summary":"Open firmware rollouts for a tenant, with progress","tags":["Firmware"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"channel":{"type":"string","enum":["stable","beta"]},"version":{"type":"string"},"kind":{"type":"string","enum":["VOICE_SATELLITE","HUB","SENSOR","OTHER"]},"status":{"type":"string","enum":["ACTIVE","PAUSED"],"description":"Only open rollouts are listed."},"startedAt":{"type":"string","format":"date-time"},"stoppedReason":{"type":["string","null"]},"total":{"type":"integer","description":"Devices of the image's kind on its channel, minus any AWAITING_REPLACEMENT."},"updated":{"type":"integer","description":"Units reporting the target version."},"failed":{"type":"integer","description":"Stored failures whose unit is still behind."},"pending":{"type":"integer"}},"required":["id","channel","version","kind","status","startedAt","stoppedReason","total","updated","failed","pending"],"additionalProperties":false}},"nextCursor":{"type":["string","null"]}},"required":["items","nextCursor"],"additionalProperties":false}}}},"403":{"description":"The caller has no access to that tenant"}},"x-alyt-service":"device-service","description":"Progress is DERIVED from the version each unit reports, never stored, so a device that updated by any route counts and one that silently rolled back stops counting. Only failure is stored, because nothing about a reported version distinguishes 'could not flash' from 'has not tried yet'.","parameters":[{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string","maxLength":200}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.RolloutController.list","x-alyt-scope":"devices:read"},"post":{"operationId":"rolloutStart","summary":"Start a rollout of one image to one tenant's fleet","tags":["Firmware"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RolloutStartResponse"}}}},"403":{"description":"Tenant admins and platform staff only"},"404":{"description":"No such firmware version"},"409":{"description":"A rollout for this image is already open"}},"x-alyt-service":"device-service","description":"Targets devices of the image's kind that follow the image's channel. Publishing an image does not move anything on its own — a rollout is the deliberate act, which is what makes it pausable.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"tenantId":{"type":"string","format":"uuid","description":"The tenant whose fleet is being moved."},"firmwareVersionId":{"type":"string","format":"uuid","description":"The image to move onto, from GET /firmware. Its kind and channel decide what is targeted."}},"required":["tenantId","firmwareVersionId"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.RolloutController.start","x-alyt-scope":"devices:write"}},"/v1/devices/rollouts/{id}":{"get":{"operationId":"rolloutDetail","summary":"One rollout, with every targeted device and its outcome","tags":["Firmware"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["ACTIVE","PAUSED","CANCELLED","COMPLETED"]},"channel":{"type":"string","enum":["stable","beta"]},"version":{"type":"string"},"kind":{"type":"string","enum":["VOICE_SATELLITE","HUB","SENSOR","OTHER"]},"startedAt":{"type":"string","format":"date-time"},"stoppedAt":{"type":["string","null"],"format":"date-time"},"stoppedReason":{"type":["string","null"]},"total":{"type":"integer","description":"Devices of the image's kind on its channel, minus any AWAITING_REPLACEMENT."},"updated":{"type":"integer","description":"Units reporting the target version."},"failed":{"type":"integer","description":"Stored failures whose unit is still behind."},"pending":{"type":"integer"},"devices":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"serial":{"type":["string","null"]},"reportedVersion":{"type":["string","null"]},"deviceStatus":{"type":"string","enum":["PROVISIONING","ONLINE","OFFLINE","DISABLED","AWAITING_REPLACEMENT"]},"state":{"type":"string","enum":["updated","failed","offered","pending"]},"error":{"type":["string","null"]},"attempts":{"type":"integer","description":"0 for a device never offered the image."}},"required":["id","name","serial","reportedVersion","deviceStatus","state","error","attempts"],"additionalProperties":false},"description":"Every targeted device, by name."}},"required":["id","status","channel","version","kind","startedAt","stoppedAt","stoppedReason","total","updated","failed","pending","devices"],"additionalProperties":false}}}},"403":{"description":"The caller has no access to that tenant"},"404":{"description":"No such rollout for that tenant"}},"x-alyt-service":"device-service","description":"Lists every device the rollout targets -- devices of the image's kind that follow its channel, minus any AWAITING_REPLACEMENT -- whether or not it has been offered the image yet. Each device's `state` is derived first from the version its unit reports (`updated`), then from the stored outcome (`failed`, `offered`), and is `pending` otherwise; `error` is set only for a failed device. The counts `updated`, `failed` and `pending` always sum to `total`. Any status is readable, including CANCELLED and COMPLETED, which is how the record of an attempt survives.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.RolloutController.detail","x-alyt-scope":"devices:read"}},"/v1/devices/rollouts/{id}/pause":{"post":{"operationId":"rolloutPause","summary":"Stop offering the image, reversibly","tags":["Firmware"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RolloutStartResponse"}}}},"403":{"description":"Tenant admins and platform staff only"},"404":{"description":"No such rollout"},"409":{"description":"Already cancelled or complete"}},"x-alyt-service":"device-service","description":"Devices already updated stay updated. Resume with the resume call.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string","minLength":1,"maxLength":300,"description":"Why the rollout was paused or cancelled."}},"required":["reason"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.RolloutController.pause","x-alyt-scope":"devices:write"}},"/v1/devices/rollouts/{id}/resume":{"post":{"operationId":"rolloutResume","summary":"Start offering the image again","tags":["Firmware"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RolloutStartResponse"}}}},"403":{"description":"Tenant admins and platform staff only"},"404":{"description":"No such rollout"},"409":{"description":"Already cancelled or complete"}},"x-alyt-service":"device-service","description":"Puts a PAUSED rollout back to ACTIVE and clears `stoppedAt` and `stoppedReason`; devices are offered the image again on their next check-in. Takes no body. A CANCELLED rollout cannot be resumed, because cancelling is final, and a COMPLETED one has nothing left to offer: start a new rollout instead.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.RolloutController.resume","x-alyt-scope":"devices:write"}},"/v1/devices/rollouts/{id}/cancel":{"post":{"operationId":"rolloutCancel","summary":"Stop the rollout for good","tags":["Firmware"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RolloutStartResponse"}}}},"403":{"description":"Tenant admins and platform staff only"},"404":{"description":"No such rollout"},"409":{"description":"Already cancelled or complete"}},"x-alyt-service":"device-service","description":"Kept as a record of what was attempted. Cancelling cannot be undone.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string","minLength":1,"maxLength":300,"description":"Why the rollout was paused or cancelled."}},"required":["reason"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.RolloutController.cancel","x-alyt-scope":"devices:write"}},"/v1/devices/rollouts/result":{"post":{"operationId":"rolloutReport","summary":"A device reporting whether it managed to flash","tags":["Firmware"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"recorded":{"const":false},"reason":{"type":"string"}},"required":["recorded","reason"],"additionalProperties":false,"description":"No open rollout is for that version; the report is ignored rather than misfiled."},{"type":"object","properties":{"recorded":{"const":true},"state":{"type":"string","enum":["updated","failed"]}},"required":["recorded","state"],"additionalProperties":false}]}}}},"403":{"description":"The caller has no access to that tenant"},"404":{"description":"No such device"}},"x-alyt-service":"device-service","description":"Success stores nothing: the unit reporting the new version IS the record, and a stored success would outlive a rollback. A failure is stored because nothing else can distinguish it from a device that has not tried yet.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"tenantId":{"type":"string","format":"uuid"},"deviceId":{"type":"string","format":"uuid"},"version":{"type":"string","minLength":1,"maxLength":64},"ok":{"type":"boolean"},"error":{"type":"string","maxLength":300}},"required":["tenantId","deviceId","version","ok"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.RolloutController.report","x-alyt-scope":"devices:write"}},"/v1/devices/{id}/firmware":{"get":{"operationId":"rolloutOffer","summary":"The image this device should install right now, or nothing","tags":["Firmware"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"update":{"const":false}},"required":["update"],"additionalProperties":false,"description":"No ACTIVE rollout covers this device, or its unit already reports the version."},{"type":"object","properties":{"update":{"const":false},"deferred":{"const":"quiet-hours"}},"required":["update","deferred"],"additionalProperties":false,"description":"Asked during the room's quiet hours. Come back later; flashing reboots the device."},{"type":"object","properties":{"update":{"const":true},"rolloutId":{"type":"string","format":"uuid"},"firmware":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"kind":{"type":"string","enum":["VOICE_SATELLITE","HUB","SENSOR","OTHER"]},"version":{"type":"string"},"url":{"type":"string"},"sha256":{"type":"string"},"notes":{"type":["string","null"]},"mandatory":{"type":"boolean"},"channel":{"type":"string","enum":["STABLE","BETA"]},"createdAt":{"type":"string","format":"date-time"}},"required":["id","kind","version","url","sha256","notes","mandatory","channel","createdAt"],"additionalProperties":false,"description":"The catalogue entry to install, not just its version string."}},"required":["update","rolloutId","firmware"],"additionalProperties":false}]}}}},"403":{"description":"The caller has no access to that tenant"},"404":{"description":"No such device"}},"x-alyt-service":"device-service","description":"Rollout-gated and channel-aware, which is what separates this from GET /firmware/latest: an image is offered only while a rollout for it is ACTIVE, so publishing a build does not start a race to install it. Answers with nothing during the room's quiet hours, because flashing reboots the device (ROADMAP P7.3).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.RolloutController.offer","x-alyt-scope":"devices:read"}},"/v1/devices/{id}/channel":{"post":{"operationId":"rolloutSetChannel","summary":"Move one device onto a firmware channel","tags":["Firmware"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"firmwareChannel":{"type":"string","enum":["STABLE","BETA"]}},"required":["id","firmwareChannel"],"additionalProperties":false}}}},"403":{"description":"Tenant admins and platform staff only"},"404":{"description":"No such device"}},"x-alyt-service":"device-service","description":"Per DEVICE, not per tenant, so a single unit can be a beta canary while the rest of the fleet stays on stable.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"channel":{"type":"string","enum":["STABLE","BETA"],"description":"Which audience's builds this device follows."}},"required":["channel"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.RolloutController.setChannel","x-alyt-scope":"devices:write"}},"/v1/devices/{id}/units":{"get":{"operationId":"replacementUnits","summary":"Every unit that has served as this device, newest first","tags":["Devices"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"serial":{"type":"string"},"model":{"type":["string","null"]},"firmware":{"type":["string","null"]},"macAddress":{"type":["string","null"]},"installedAt":{"type":"string","format":"date-time"},"removedAt":{"type":["string","null"],"format":"date-time"},"removedReason":{"type":["string","null"]},"current":{"type":"boolean","description":"True for the unit serving as the device now."}},"required":["id","serial","model","firmware","macAddress","installedAt","removedAt","removedReason","current"],"additionalProperties":false}}}}},"404":{"description":"No such device in that tenant"}},"x-alyt-service":"device-service","description":"The RMA record. A retired unit keeps its row because support and warranty questions are asked about SERIALS, so a serial must stay resolvable after the hardware is gone.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.ReplacementController.units","x-alyt-scope":"devices:read"}},"/v1/devices/{id}/dependents":{"get":{"operationId":"replacementDependents","summary":"What removing this device would affect","tags":["Devices"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"deviceId":{"type":"string","format":"uuid"},"name":{"type":"string"},"serial":{"type":["string","null"]},"effects":{"type":"array","items":{"anyOf":[{"type":"object","properties":{"kind":{"const":"room"},"summary":{"type":"string"},"count":{"type":"integer"}},"required":["kind","summary","count"],"additionalProperties":false,"description":"Present only when the device is in a room. `count` is the speakers there."},{"type":"object","properties":{"kind":{"const":"children"},"summary":{"type":"string"},"count":{"type":"integer"},"names":{"type":"array","items":{"type":"string"}}},"required":["kind","summary","count","names"],"additionalProperties":false,"description":"Present only for a hub with children, which are removed with it."},{"type":"object","properties":{"kind":{"const":"history"},"summary":{"type":"string"},"count":{"type":"integer"}},"required":["kind","summary","count"],"additionalProperties":false,"description":"Telemetry rows plus commands, which a soft delete keeps."},{"type":"object","properties":{"kind":{"const":"hardware"},"summary":{"type":"string"},"count":{"type":"integer"}},"required":["kind","summary","count"],"additionalProperties":false,"description":"Units that have served as this device; their serials stay resolvable."}]},"description":"In the order a person cares about: what stops working, then what is kept."},"reversible":{"type":"boolean"},"reversibleNote":{"type":"string"}},"required":["deviceId","name","serial","effects","reversible","reversibleNote"],"additionalProperties":false}}}},"404":{"description":"No such device in that tenant"}},"x-alyt-service":"device-service","description":"Shown BEFORE a removal is confirmed. No product surveyed ships this: Apple leaves dangling automations and SmartThings silently deletes them. The sharp case is a hub — devices acquired through it are removed with it.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.ReplacementController.dependents","x-alyt-scope":"devices:read"}},"/v1/devices/{id}/retire-unit":{"post":{"operationId":"replacementRetire","summary":"Retire this device's hardware, keeping the device itself","tags":["Devices"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"deviceId":{"type":"string","format":"uuid"},"retiredSerial":{"type":"string"},"status":{"const":"AWAITING_REPLACEMENT"},"nextStep":{"type":"string"}},"required":["deviceId","retiredSerial","status","nextStep"],"additionalProperties":false}}}},"400":{"description":"This device has no hardware to retire"},"403":{"description":"Insufficient role"},"404":{"description":"No such device in that tenant"}},"x-alyt-service":"device-service","description":"The RMA path. The device keeps its room, settings and history and goes to AWAITING_REPLACEMENT; the unit is kept with a removal reason. The replacement is then registered with a pairing token minted for THIS device, which adopts the new hardware instead of creating a second device. Requires INSTALLER or ADMIN.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string","minLength":1,"maxLength":200}},"required":["reason"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.ReplacementController.retire","x-alyt-scope":"devices:write"}},"/v1/devices":{"get":{"operationId":"devicesList","summary":"List the devices in a tenant","tags":["Devices"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/DevicesListItem"}},"nextCursor":{"type":["string","null"]}},"required":["items","nextCursor"],"additionalProperties":false}}}},"403":{"description":"The caller has no access to that tenant"}},"x-alyt-service":"device-service","description":"Returns the standard list envelope: `{ items, nextCursor }`. Follow `nextCursor` until it is null; never decide there is more from `items.length`.","parameters":[{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on. Required, never inferred."}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string","maxLength":200}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.DevicesController.list","x-alyt-scope":"devices:read"}},"/v1/devices/{id}":{"get":{"operationId":"devicesGet","summary":"One device","tags":["Devices"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DevicesListItem"}}}},"404":{"description":"No such device in that tenant"}},"x-alyt-service":"device-service","description":"A device that exists in another tenant is a 404, not a 403: whether an id exists elsewhere is not something a caller may learn.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on. Required, never inferred."}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.DevicesController.get","x-alyt-scope":"devices:read"},"delete":{"operationId":"devicesRemove","summary":"Soft-delete a device","tags":["Devices"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"}},"required":["id"],"additionalProperties":false}}}},"403":{"description":"Insufficient role"},"404":{"description":"No such device in that tenant"}},"x-alyt-service":"device-service","description":"The row is retained so history and audit survive. ADMIN only.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on. Required, never inferred."}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.DevicesController.remove","x-alyt-scope":"devices:write"}},"/v1/devices/{id}/settings":{"patch":{"operationId":"devicesUpdateSettings","summary":"Change a device's settings","tags":["Devices"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DevicesListItem"}}}},"403":{"description":"Insufficient role"},"404":{"description":"No such device in that tenant"}},"x-alyt-service":"device-service","description":"Every change is audited with the actor and the before/after values. Tenant staff need INSTALLER or ADMIN; a household's OWNER may change their own device, and a MEMBER or GUEST may not.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on. Required, never inferred."}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":80},"enabled":{"type":"boolean"},"roomId":{"type":["string","null"],"format":"uuid"},"voice":{"type":"object","properties":{"announceVolume":{"type":"integer","minimum":0,"maximum":100},"playbackMode":{"type":"string","enum":["auto","audioclip","full"]},"personaMap":{"type":"object","additionalProperties":{"type":"string"}}},"additionalProperties":false},"audio":{"type":"object","properties":{"outputVolume":{"type":"integer","minimum":0,"maximum":100}},"additionalProperties":false},"hub":{"type":"object","properties":{"permitJoin":{"type":"boolean"}},"additionalProperties":false},"sensor":{"type":"object","properties":{"reportIntervalSeconds":{"type":"integer","minimum":1,"maximum":86400}},"additionalProperties":false}},"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.DevicesController.updateSettings","x-alyt-scope":"devices:write"}},"/v1/devices/{id}/commands":{"post":{"operationId":"devicesCommand","summary":"Queue a command for a device","tags":["Devices"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"The command row's id. BigInt in the store, a decimal string on the wire."},"command":{"type":"string"},"status":{"const":"pending"},"issuedAt":{"type":"string","format":"date-time"}},"required":["id","command","status","issuedAt"],"additionalProperties":false}}}},"400":{"description":"Not a control this device offers, or a bad value"},"403":{"description":"Insufficient role"},"404":{"description":"No such device in that tenant"}},"x-alyt-service":"device-service","description":"Delivered over MQTT. Two envelopes: `set` / `press` with `{ capability, key, value }` is the thing envelope every client uses — validated against the device's own descriptors (a 400 names the reason) and open to anyone who can see the house. Any other `command` is free-form and requires the INSTALLER or ADMIN role.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on. Required, never inferred."}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"command":{"type":"string","enum":["set","press"]},"payload":{"$ref":"#/components/schemas/DevicesCommandOption1Payload"}},"required":["command","payload"],"additionalProperties":false},{"type":"object","properties":{"command":{"type":"string","minLength":1,"maxLength":64},"payload":{"type":"object","additionalProperties":{}}},"required":["command"],"additionalProperties":false}]}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.DevicesController.command","x-alyt-scope":"devices:commands"}},"/v1/devices/{id}/telemetry":{"get":{"operationId":"devicesTelemetry","summary":"Recent telemetry for a device, newest first","tags":["Devices"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"BigInt in the store, a decimal string on the wire."},"deviceId":{"type":"string","format":"uuid"},"metric":{"type":"string"},"value":{"type":["number","null"]},"payload":{"anyOf":[{},{"type":"null"}]},"recordedAt":{"type":"string","format":"date-time"}},"required":["id","deviceId","metric","value","payload","recordedAt"],"additionalProperties":false}},"nextCursor":{"type":["string","null"]}},"required":["items","nextCursor"],"additionalProperties":false}}}},"404":{"description":"No such device in that tenant"}},"x-alyt-service":"device-service","description":"Returns the standard list envelope, ordered by `recordedAt` descending with the row id as the tie-break, so readings that arrive while a client is paging never shift a page. Telemetry ids are BigInt in the store and cross the wire as decimal strings. `value` is null for a reading that carried only a payload. Reading telemetry means reading the device, so the same narrowing applies: a household member sees only devices inside their own client, and any other device is a 404.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on. Required, never inferred."}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string","maxLength":200}},{"name":"metric","in":"query","required":false,"schema":{"type":"string","maxLength":64}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.DevicesController.telemetry","x-alyt-scope":"telemetry:read"}},"/v1/pairing-tokens":{"post":{"operationId":"pairingMint","summary":"Mint a pairing token for admitting hardware to a tenant","tags":["Devices"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"token":{"type":"string","description":"The raw token. Returned exactly once; only its hash is stored."},"expiresAt":{"type":"string","format":"date-time"}},"required":["id","token","expiresAt"],"additionalProperties":false}}}},"403":{"description":"Insufficient role on that tenant"}},"x-alyt-service":"device-service","description":"The token a device presents to POST /devices/register. Stored hashed and single-use. Requires the INSTALLER or ADMIN role: minting one is the act of admitting hardware to a tenant, not an ordinary read.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"replacesDeviceId":{"type":"string","format":"uuid"},"tenantId":{"type":"string","format":"uuid"},"propertyId":{"type":"string","format":"uuid"},"roomId":{"type":"string","format":"uuid"},"ttlSeconds":{"type":"integer","minimum":0}},"required":["tenantId"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.PairingController.mint","x-alyt-scope":"devices:write"}},"/v1/firmware":{"get":{"operationId":"firmwareList","summary":"List published firmware images","tags":["Firmware"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/FirmwareListItem"}},"nextCursor":{"type":["string","null"]}},"required":["items","nextCursor"],"additionalProperties":false}}}},"400":{"description":"kind or channel is not one of the known values"}},"x-alyt-service":"device-service","description":"The catalogue is platform-wide, not tenant-scoped: an image belongs to a hardware kind, and every tenant running that kind sees the same rows. Newest first, in the standard list envelope, optionally narrowed to one kind and one channel. Listing says what exists and moves nothing: what a given device should install right now is GET /devices/{id}/firmware, which is gated on an ACTIVE rollout. The AGENT kind never appears here, because the agent is software with no image to flash.","parameters":[{"name":"kind","in":"query","required":false,"schema":{"type":"string","enum":["VOICE_SATELLITE","HUB","SENSOR","OTHER"],"description":"Device kind the image is for. Note ROADMAP P8.1: a device will carry a SET of capabilities rather than one kind, and this parameter is expected to follow."}},{"name":"channel","in":"query","required":false,"schema":{"type":"string","enum":["STABLE","BETA"],"description":"Which audience's builds to consider. Defaults to STABLE."}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string","maxLength":200}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.FirmwareController.list","x-alyt-scope":"firmware:read"},"post":{"operationId":"firmwarePublish","summary":"Publish a firmware image to the catalogue","tags":["Firmware"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FirmwareListItem"}}}},"403":{"description":"Platform staff only"}},"x-alyt-service":"device-service","description":"ALYT staff only. The catalogue is platform property: an image every tenant's hardware may flash is not a tenant admin's to publish.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"kind":{"type":"string","enum":["VOICE_SATELLITE","HUB","SENSOR","OTHER"]},"version":{"type":"string","minLength":1,"maxLength":32},"url":{"type":"string","format":"uri"},"sha256":{"type":"string","pattern":"^[a-f0-9]{64}$"},"notes":{"type":"string","maxLength":2000},"mandatory":{"type":"boolean"},"channel":{"type":"string","enum":["STABLE","BETA"]}},"required":["kind","version","url","sha256"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.FirmwareController.publish","x-alyt-scope":"session"}},"/v1/firmware/latest":{"get":{"operationId":"firmwareLatest","summary":"The image a device on a given version should move to, if any","tags":["Firmware"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"update":{"const":false}},"required":["update"],"additionalProperties":false,"description":"Nothing is published for that kind and channel, or the device is already on the newest."},{"type":"object","properties":{"update":{"const":true},"firmware":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"kind":{"type":"string","enum":["VOICE_SATELLITE","HUB","SENSOR","OTHER"]},"version":{"type":"string"},"url":{"type":"string"},"sha256":{"type":"string"},"notes":{"type":["string","null"]},"mandatory":{"type":"boolean"},"channel":{"type":"string","enum":["STABLE","BETA"]},"createdAt":{"type":"string","format":"date-time"}},"required":["id","kind","version","url","sha256","notes","mandatory","channel","createdAt"],"additionalProperties":false,"description":"The catalogue entry to move to."}},"required":["update","firmware"],"additionalProperties":false}]}}}},"400":{"description":"kind is missing or not one of the known values"}},"x-alyt-service":"device-service","description":"Answers with nothing when the device is already current. This is the CATALOGUE question -- what exists. What a particular device should install RIGHT NOW is GET /devices/{id}/firmware, which is gated on an active rollout and on the room's quiet hours.","parameters":[{"name":"kind","in":"query","required":true,"schema":{"type":"string","enum":["VOICE_SATELLITE","HUB","SENSOR","OTHER"],"description":"Device kind the image is for. Note ROADMAP P8.1: a device will carry a SET of capabilities rather than one kind, and this parameter is expected to follow."}},{"name":"current","in":"query","required":false,"schema":{"type":"string"}},{"name":"channel","in":"query","required":false,"schema":{"type":"string","enum":["STABLE","BETA"],"description":"Which audience's builds to consider. Defaults to STABLE."}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.FirmwareController.latest","x-alyt-scope":"firmware:read"}},"/v1/capabilities":{"get":{"operationId":"capabilitiesList","summary":"The capability catalogue: every capability, its controls and its room actions","tags":["Devices"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"schemaVersion":{"type":"integer"},"items":{"type":"array","items":{"$ref":"#/components/schemas/CapabilitiesListItem"}}},"required":["schemaVersion","items"],"additionalProperties":false}}}}},"x-alyt-service":"device-service","description":"Static per build of the platform. `schemaVersion` bumps only when an existing control changes meaning; adding is never a bump. A client that meets a primitive it does not know renders a labelled read-only row, never nothing.","security":[{"bearerAuth":[]}],"x-alyt-handler":"device-service.CapabilitiesController.list","x-alyt-scope":null}},"/v1/tenants/{tenantId}/activity":{"get":{"operationId":"activityList","summary":"Read the tenant's audit trail","tags":["Tenants"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"The BigInt id as a decimal string, as telemetry ids are."},"action":{"type":"string"},"target":{"type":["string","null"]},"actorId":{"type":["string","null"]},"outcome":{"type":"string"},"detail":{},"at":{"type":"string","format":"date-time"}},"required":["id","action","target","actorId","outcome","detail","at"],"additionalProperties":false}},"nextCursor":{"type":["string","null"]}},"required":["items","nextCursor"],"additionalProperties":false}}}},"403":{"description":"Not staff on that tenant"}},"x-alyt-service":"tenant-service","description":"Every settings change, registration and deletion, with the actor and the before/after values, newest first. It has been written since device-service existed and was unreadable until now. A null actor is meaningful rather than missing: it is how a device registering ITSELF is recorded, because the actor there is hardware. Tenant staff only — an audit trail is an operator surface.","parameters":[{"name":"tenantId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string","maxLength":200}},{"name":"action","in":"query","required":false,"schema":{"type":"string"}},{"name":"actorId","in":"query","required":false,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.ActivityController.list","x-alyt-scope":"tenant:read"}},"/v1/tenants/{tenantId}/clients/{clientId}/members":{"get":{"operationId":"membershipsList","summary":"Who has access to this client, and until when","tags":["Tenants"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"userId":{"type":"string","format":"uuid"},"email":{"type":"string"},"name":{"type":["string","null"]},"role":{"type":"string","enum":["OWNER","MEMBER","GUEST"]},"propertyId":{"type":["string","null"],"format":"uuid"},"expiresAt":{"type":["string","null"],"format":"date-time"},"expired":{"type":"boolean","description":"True when `expiresAt` has passed. The row still exists and still shows, but grants nothing."}},"required":["id","userId","email","name","role","propertyId","expiresAt","expired"],"additionalProperties":false}},"nextCursor":{"type":["string","null"]}},"required":["items","nextCursor"],"additionalProperties":false}}}},"403":{"description":"Not staff on that tenant"},"404":{"description":"No such client in this tenant"}},"x-alyt-service":"tenant-service","description":"An expired grant still appears, flagged `expired: true`, because knowing that someone HAD access is part of knowing who has it now.","parameters":[{"name":"tenantId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"clientId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.MembershipsController.list","x-alyt-scope":"tenant:read"},"post":{"operationId":"membershipsGrant","summary":"Grant or change someone's access, optionally with an expiry","tags":["Tenants"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"userId":{"type":"string","format":"uuid"},"clientId":{"type":"string","format":"uuid"},"role":{"type":"string","enum":["OWNER","MEMBER","GUEST"]},"propertyId":{"type":["string","null"],"format":"uuid"},"expiresAt":{"type":["string","null"],"format":"date-time"}},"required":["id","userId","clientId","role","propertyId","expiresAt"],"additionalProperties":false}}}},"403":{"description":"Not staff on that tenant"},"404":{"description":"No such client in this tenant"},"500":{"description":"Sessions could not be revoked, so the grant was not changed"}},"x-alyt-service":"tenant-service","description":"Changing a grant REVOKES the user's sessions as well as creating it. Narrowing someone from OWNER to GUEST while their existing token still says OWNER would make the change advisory until that token expired.","parameters":[{"name":"tenantId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"clientId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"userId":{"type":"string","format":"uuid"},"role":{"type":"string","enum":["OWNER","MEMBER","GUEST"]},"propertyId":{"type":"string","format":"uuid"},"expiresAt":{"type":"string","format":"date-time"}},"required":["userId","role"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.MembershipsController.grant","x-alyt-scope":"session"}},"/v1/tenants/{tenantId}/clients/{clientId}/members/{membershipId}":{"delete":{"operationId":"membershipsRevoke","summary":"Remove someone's access and end their sessions immediately","tags":["Tenants"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"userId":{"type":"string","format":"uuid"},"sessionsRevoked":{"const":true}},"required":["id","userId","sessionsRevoked"],"additionalProperties":false}}}},"403":{"description":"Not staff on that tenant"},"404":{"description":"No such membership for this client"},"500":{"description":"Sessions could not be revoked, so access was NOT removed"}},"x-alyt-service":"tenant-service","description":"THE SESSIONS ARE REVOKED FIRST, and a failure to revoke aborts the removal. A removal that reports success while the person stays signed in is the failure 16 of 19 evaluated smart-home products have, and it is the one this endpoint exists to avoid.","parameters":[{"name":"tenantId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"clientId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"membershipId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.MembershipsController.revoke","x-alyt-scope":"session"}},"/v1/tenants":{"get":{"operationId":"tenantsList","summary":"List the tenants the caller can see","tags":["Tenants"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/TenantsListItem"}},"nextCursor":{"type":["string","null"]}},"required":["items","nextCursor"],"additionalProperties":false}}}}},"x-alyt-service":"tenant-service","description":"ALYT staff see every tenant; anyone else sees only the tenants they are a member of. There is deliberately no way to ask about a tenant you are not in.","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string","maxLength":200}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.TenantsController.list","x-alyt-scope":"tenant:read"},"post":{"operationId":"tenantsCreate","summary":"Create a tenant","tags":["Tenants"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantsListItem"}}}},"403":{"description":"Platform staff only"},"409":{"description":"That tenant code is already taken"}},"x-alyt-service":"tenant-service","description":"The 3-character code is what device serials carry, so it decides which tenant a device belongs to when it registers. ALYT staff only.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":120},"code":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z0-9]{3}$"},"placeholder":{"type":"boolean"}},"required":["name","code"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.TenantsController.create","x-alyt-scope":"session"}},"/v1/tenants/{tenantId}":{"get":{"operationId":"tenantsGet","summary":"One tenant","tags":["Tenants"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantsListItem"}}}},"403":{"description":"The caller is not a member of that tenant"}},"x-alyt-service":"tenant-service","description":"Platform staff, or a tenant ADMIN of that tenant: a tenant ADMIN could otherwise never see their own organisation's name, and the console would show a raw uuid. An INSTALLER and every household account (a client membership, not a tenant one) are refused. Creating, changing and deleting tenants are platform-staff only.","parameters":[{"name":"tenantId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.TenantsController.get","x-alyt-scope":"tenant:read"},"patch":{"operationId":"tenantsUpdate","summary":"Update a tenant","tags":["Tenants"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantsListItem"}}}},"403":{"description":"Insufficient role on that tenant"}},"x-alyt-service":"tenant-service","description":"Platform staff only, the same rule as creating one. Changes the name, the status (ACTIVE or SUSPENDED) and the placeholder flag. The 3-character code is not changeable here because device serials carry it, and the two privacy switches on the row (cloud inference, transcript storage) are not settable through this route either.","parameters":[{"name":"tenantId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":120},"status":{"type":"string","enum":["ACTIVE","SUSPENDED"]},"placeholder":{"type":"boolean"}},"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.TenantsController.update","x-alyt-scope":"session"},"delete":{"operationId":"tenantsRemove","summary":"Delete a tenant","tags":["Tenants"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"const":true}},"required":["deleted"],"additionalProperties":false}}}},"403":{"description":"Platform staff only"}},"x-alyt-service":"tenant-service","description":"Platform staff only, and immediate: there is no soft delete and no confirmation step. The database cascades to the tenant's clients, properties and rooms, its devices and their units, voice turns, rollouts, notifications and tenant memberships; audit-log entries survive with their tenant reference set to null. Integrations and their endpoints carry the tenant id without a database relation and are not deleted with it.","parameters":[{"name":"tenantId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.TenantsController.remove","x-alyt-scope":"session"}},"/v1/tenants/{tenantId}/export":{"get":{"operationId":"tenantsExport","summary":"The tenant as a portable document: its properties, rooms, connectors, endpoints and devices, never a credential","tags":["Tenants"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantsImportRequest"}}}},"403":{"description":"Not a member of this tenant"},"404":{"description":"Tenant not found"}},"x-alyt-service":"tenant-service","description":"For moving a house to another install (self-hosting) or keeping a copy. Connector fields the descriptor marks `secret` are removed and listed in `redactedFields`; devices travel with their unit serials and re-register on the new install. History (telemetry, commands, audit, notifications) is not part of a house and is not exported. Staff, or a member of the tenant.","parameters":[{"name":"tenantId","in":"path","required":true,"schema":{"type":"string"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.TenantsController.export","x-alyt-scope":"tenant:read"}},"/v1/tenants/import":{"post":{"operationId":"tenantsImport","summary":"Recreate a tenant from an export document, under the same code, in one transaction","tags":["Tenants"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"tenantId":{"type":"string","format":"uuid"},"code":{"type":"string"},"counts":{"type":"object","properties":{"clients":{"type":"integer"},"properties":{"type":"integer"},"rooms":{"type":"integer"},"integrations":{"type":"integer"},"endpoints":{"type":"integer"},"devices":{"type":"integer"},"units":{"type":"integer"}},"required":["clients","properties","rooms","integrations","endpoints","devices","units"],"additionalProperties":false},"needsReconnect":{"type":"array","items":{"type":"object","properties":{"integrationId":{"type":"string","format":"uuid"},"kind":{"type":"string"},"fields":{"type":"array","items":{"type":"string"}}},"required":["integrationId","kind","fields"],"additionalProperties":false}},"missingDefinitions":{"type":"array","items":{"type":"string"}}},"required":["tenantId","code","counts","needsReconnect","missingDefinitions"],"additionalProperties":false}}}},"403":{"description":"Not platform staff"},"409":{"description":"The code or a serial already exists here, or the document is inconsistent"}},"x-alyt-service":"tenant-service","description":"Platform staff only (on a self-hosted install, the operator). Refused with 409 when a tenant with the same three-letter code or a device unit with the same serial exists here: the code is in every serial, so the original must be gone first. Connectors exported without their secrets come back as `needs-reconnect` and are listed with the fields to supply; a connector whose definition this install lacks is skipped and named in `missingDefinitions`. Devices come back in PROVISIONING and re-register by serial.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantsImportRequest"}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.TenantsController.import","x-alyt-scope":"session"}},"/v1/tenants/{tenantId}/clients":{"get":{"operationId":"clientsList","summary":"List a tenant's clients","tags":["Tenants"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/ClientsListItem"}},"nextCursor":{"type":["string","null"]}},"required":["items","nextCursor"],"additionalProperties":false}}}},"403":{"description":"The caller is not a member of that tenant"}},"x-alyt-service":"tenant-service","description":"Platform staff, or a tenant ADMIN. This enumerates every customer of the brand, so it is closed to household accounts and to INSTALLERs, and it stays that way: a household names its own client on `GET /properties`, and an installer lists a tenant's properties there without a client. Paged by name.","parameters":[{"name":"tenantId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string","maxLength":200}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.ClientsController.list","x-alyt-scope":"tenant:read"},"post":{"operationId":"clientsCreate","summary":"Create a client — the household or organisation a property belongs to","tags":["Tenants"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientsListItem"}}}},"403":{"description":"The caller is not a member of that tenant"}},"x-alyt-service":"tenant-service","description":"Platform staff, or a tenant ADMIN. The client is created in the tenant named in the path, and its name is unique within that tenant. Access for the household itself is granted afterwards through `POST /tenants/:tenantId/clients/:clientId/members`.","parameters":[{"name":"tenantId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":120},"email":{"type":"string","format":"email"}},"required":["name"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.ClientsController.create","x-alyt-scope":"session"}},"/v1/tenants/{tenantId}/clients/{clientId}":{"get":{"operationId":"clientsGet","summary":"One client","tags":["Tenants"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientsListItem"}}}},"403":{"description":"The caller is not a member of that tenant"}},"x-alyt-service":"tenant-service","description":"Platform staff, or a tenant ADMIN. Read through the tenant in the path: a client id from another tenant matches no row and is a 404, never a leak. A household cannot read its own client here; it sees the house through its properties and rooms.","parameters":[{"name":"tenantId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"clientId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.ClientsController.get","x-alyt-scope":"tenant:read"},"patch":{"operationId":"clientsUpdate","summary":"Update a client","tags":["Tenants"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientsListItem"}}}},"403":{"description":"The caller is not a member of that tenant"}},"x-alyt-service":"tenant-service","description":"Platform staff, or a tenant ADMIN. Name and email only; `email: null` clears it. The row is loaded through the tenant first and written by id only once it is proven to belong to that tenant.","parameters":[{"name":"tenantId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"clientId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":120},"email":{"type":["string","null"],"format":"email"}},"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.ClientsController.update","x-alyt-scope":"session"},"delete":{"operationId":"clientsRemove","summary":"Delete a client","tags":["Tenants"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"const":true}},"required":["deleted"],"additionalProperties":false}}}},"403":{"description":"The caller is not a member of that tenant"}},"x-alyt-service":"tenant-service","description":"Platform staff, or a tenant ADMIN. The database cascades to the client's properties and their rooms, and to every client membership, so the household's access ends with it. Devices in those properties are kept and lose their property and room (set to null); the client's integrations and endpoints remain, as they carry the client id without a database relation.","parameters":[{"name":"tenantId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"clientId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.ClientsController.remove","x-alyt-scope":"session"}},"/v1/properties":{"get":{"operationId":"propertiesList","summary":"List a client's properties, or every property of a tenant for its staff","tags":["Properties"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/PropertiesListItem"}},"nextCursor":{"type":["string","null"]}},"required":["items","nextCursor"],"additionalProperties":false}}}},"403":{"description":"The caller has no access to that client, or is a household asking without naming its client"}},"x-alyt-service":"tenant-service","description":"With `clientId`: that client's properties; a GUEST narrowed to one property sees only that property, not an error for the others (the narrowing is applied to the result, not asserted after the fact). Without `clientId`: every property of the tenant, which only tenant staff who may install (ADMIN, INSTALLER, platform) may ask for. An installer needs the properties it installs into without being allowed to enumerate the tenant's customers, which `GET /tenants/:id/clients` reserves for administrators.","parameters":[{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"clientId","in":"query","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string","maxLength":200}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.PropertiesController.list","x-alyt-scope":"tenant:read"},"post":{"operationId":"propertiesCreate","summary":"Create a property","tags":["Properties"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropertiesListItem"}}}},"403":{"description":"The caller cannot manage that client"}},"x-alyt-service":"tenant-service","description":"Tenant staff (ADMIN, INSTALLER, platform), or the client's OWNER. `tenantId` and `clientId` are in the body: the caller states the scope and the claims decide, nothing is inferred. `timezone` defaults to UTC and is what the rooms' quiet hours are read in.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"tenantId":{"type":"string","format":"uuid"},"clientId":{"type":"string","format":"uuid"},"name":{"type":"string","minLength":1,"maxLength":120},"timezone":{"type":"string","minLength":1,"maxLength":64}},"required":["tenantId","clientId","name"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.PropertiesController.create","x-alyt-scope":"rooms:write"}},"/v1/properties/{propertyId}":{"get":{"operationId":"propertiesGet","summary":"One property","tags":["Properties"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropertiesListItem"}}}},"403":{"description":"The caller cannot view that property"}},"x-alyt-service":"tenant-service","description":"Tenant staff (ADMIN, INSTALLER, platform) see any property of the tenant; a household sees its own client's, and a GUEST pinned to one property sees only that one. The property is loaded by id and tenant before the check, so a property of another tenant is a 404.","parameters":[{"name":"propertyId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.PropertiesController.get","x-alyt-scope":"tenant:read"},"patch":{"operationId":"propertiesUpdate","summary":"Update a property","tags":["Properties"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropertiesListItem"}}}},"403":{"description":"The caller cannot manage that property"}},"x-alyt-service":"tenant-service","description":"Tenant staff, or the client's OWNER; an OWNER whose membership is pinned to a different property is refused. Name and timezone only: a property cannot be moved to another client.","parameters":[{"name":"propertyId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":120},"timezone":{"type":"string","minLength":1,"maxLength":64},"address":{"type":["string","null"],"maxLength":200}},"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.PropertiesController.update","x-alyt-scope":"rooms:write"},"delete":{"operationId":"propertiesRemove","summary":"Delete a property","tags":["Properties"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"const":true}},"required":["deleted"],"additionalProperties":false}}}},"403":{"description":"The caller cannot manage that property"}},"x-alyt-service":"tenant-service","description":"Tenant staff, or the client's OWNER. The database cascades to the property's rooms. Devices in it survive with their property and room set to null, so they read as unassigned and are paired to nothing; endpoints in its rooms survive the same way. Memberships pinned to this property are not removed.","parameters":[{"name":"propertyId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.PropertiesController.remove","x-alyt-scope":"rooms:write"}},"/v1/properties/{propertyId}/overview":{"get":{"operationId":"propertiesOverview","summary":"The property as a home screen: rooms, their things, and the unassigned","tags":["Properties"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"property":{"$ref":"#/components/schemas/PropertiesOverviewProperty"},"house":{"$ref":"#/components/schemas/PropertiesOverviewHouse"},"rooms":{"type":"array","items":{"$ref":"#/components/schemas/PropertiesOverviewRoomsItem"}},"unassigned":{"type":"array","items":{"$ref":"#/components/schemas/DevicesListItemThing"}},"generatedAt":{"type":"string","format":"date-time"}},"required":["property","house","rooms","unassigned","generatedAt"],"additionalProperties":false}}}},"403":{"description":"The caller cannot view that property"},"404":{"description":"No such property in that tenant"}},"x-alyt-service":"tenant-service","description":"One call for pull-to-refresh. Each room carries `things` (every member described by its capabilities, bound to control primitives, with live state and liveness), its resolved `icon`, `listening`, and the two `primaryActions` its tile shows. `unassigned` lists this property's devices and this client's endpoints that are in no room. Scoped by tenant and property in the query, never filtered afterwards.","parameters":[{"name":"propertyId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.PropertiesController.overview","x-alyt-scope":"tenant:read"}},"/v1/properties/{propertyId}/refresh":{"post":{"operationId":"propertiesRefresh","summary":"Refresh the live state of every endpoint in the property, then return the overview","tags":["Properties"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"property":{"$ref":"#/components/schemas/PropertiesOverviewProperty"},"house":{"$ref":"#/components/schemas/PropertiesOverviewHouse"},"rooms":{"type":"array","items":{"$ref":"#/components/schemas/PropertiesOverviewRoomsItem"}},"unassigned":{"type":"array","items":{"$ref":"#/components/schemas/DevicesListItemThing"}},"generatedAt":{"type":"string","format":"date-time"},"failed":{"type":"array","items":{"$ref":"#/components/schemas/PropertiesRunActionFailedItem"}}},"required":["property","house","rooms","unassigned","generatedAt","failed"],"additionalProperties":false}}}},"403":{"description":"The caller cannot view that property"}},"x-alyt-service":"tenant-service","description":"The overview, after asking each integration's connector for the current state of its endpoints and storing what they said. `failed` names any endpoint whose connector did not answer; it is still listed with what was last known. A POST because it changes stored state; anyone who can view the property may call it.","parameters":[{"name":"propertyId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.PropertiesController.refresh","x-alyt-scope":"rooms:write"}},"/v1/properties/{propertyId}/actions":{"post":{"operationId":"propertiesRunAction","summary":"Run a room action in every room of the property","tags":["Properties"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropertiesRunActionResponse"}}}},"403":{"description":"The caller cannot control that property"}},"x-alyt-service":"tenant-service","description":"The same body as `POST /rooms/:roomId/actions`, applied to every room; the outcome is summed per thing.","parameters":[{"name":"propertyId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropertiesRunActionRequest"}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.PropertiesController.runAction","x-alyt-scope":"rooms:write"}},"/v1/properties/{propertyId}/dashboard":{"get":{"operationId":"propertiesDashboard","summary":"The dashboard's pins","tags":["Properties"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropertiesDashboardResponse"}}}},"403":{"description":"The caller cannot view that property"}},"x-alyt-service":"tenant-service","description":"Anyone who can view the property. The ordered list of what the household pinned: a thing (rendered from its own descriptors: a lock tile, a thermostat, a readout) or a routine (a Run tile). Stored on the property, so the console and the phone show the same board; a pin whose thing is gone is simply not drawn (docs/ROUTINES.md).","parameters":[{"name":"propertyId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.PropertiesController.dashboard","x-alyt-scope":"tenant:read"},"put":{"operationId":"propertiesSetDashboard","summary":"Replace the dashboard's pins","tags":["Properties"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropertiesDashboardResponse"}}}},"400":{"description":"A pin names something not in this home"},"403":{"description":"The caller cannot manage that property"}},"x-alyt-service":"tenant-service","description":"Tenant staff, or the client's OWNER. The whole ordered list, at most forty; every thing and routine named must be in this home, or nothing is written and the answer says which is not.","parameters":[{"name":"propertyId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"pins":{"type":"array","items":{"anyOf":[{"$ref":"#/components/schemas/PropertiesDashboardPinsItemOption1"},{"$ref":"#/components/schemas/PropertiesDashboardPinsItemOption2"}]},"maxItems":40},"tenantId":{"type":"string","format":"uuid"}},"required":["pins","tenantId"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.PropertiesController.setDashboard","x-alyt-scope":"rooms:write"}},"/v1/rooms":{"get":{"operationId":"roomsList","summary":"List the rooms of a property","tags":["Rooms"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RoomsListItem"}},"nextCursor":{"type":["string","null"]}},"required":["items","nextCursor"],"additionalProperties":false}}}},"403":{"description":"The caller cannot view that property"}},"x-alyt-service":"tenant-service","description":"Anyone who can view the property: tenant staff, the client's members, and a GUEST only for the property it is pinned to. Answers the rows as stored, paged by name, with no members: `icon` is the chosen token or null for the name's default, and `primaryActions` the chosen pair or null for the computed defaults. The resolved tile view is `GET /rooms/:roomId` and the property overview.","parameters":[{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}},{"name":"propertyId","in":"query","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string","maxLength":200}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.RoomsController.list","x-alyt-scope":"tenant:read"},"post":{"operationId":"roomsCreate","summary":"Create a room in a property","tags":["Rooms"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoomsListItem"}}}},"403":{"description":"The caller cannot manage that property"}},"x-alyt-service":"tenant-service","description":"Tenant staff, or the client's OWNER: configuring the house is the OWNER's, while MEMBER and GUEST may control it but not shape it. The name is unique within the property. `icon` is optional and stored as null for the name's default, which is how Sonos discovery creates rooms too, so a room typed here and one found on the network cannot disagree.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"tenantId":{"type":"string","format":"uuid"},"propertyId":{"type":"string","format":"uuid"},"name":{"type":"string","minLength":1,"maxLength":120},"icon":{"type":"string"}},"required":["tenantId","propertyId","name"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.RoomsController.create","x-alyt-scope":"rooms:write"}},"/v1/rooms/suggestions":{"get":{"operationId":"roomsSuggestions","summary":"Suggested room names with their icons, and the icon set a room may use","tags":["Rooms"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"icon":{"type":"string","pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"},"aliases":{"type":"array","items":{"type":"string"}}},"required":["name","icon"],"additionalProperties":false}},"icons":{"type":"array","items":{"type":"string","pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"}},"defaultIcon":{"type":"string","pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"}},"required":["items","icons","defaultIcon"],"additionalProperties":false}}}}},"x-alyt-service":"tenant-service","description":"For the add-a-room screen: pick a name from here or type any name. `icons` is the closed set `PATCH /rooms/:roomId` accepts for `icon`.","security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.RoomsController.suggestions","x-alyt-scope":"tenant:read"}},"/v1/rooms/{roomId}":{"get":{"operationId":"roomsGet","summary":"One room, with the devices and endpoints in it","tags":["Rooms"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropertiesOverviewRoomsItem"}}}},"403":{"description":"The caller cannot view the property that owns the room"}},"x-alyt-service":"tenant-service","description":"Co-membership made visible. A device and an endpoint listed here are bound to each other by being in this room: there is no pairing call, and never will be. `things` describes every member the same way whichever table it lives in — capabilities bound to control primitives, live state, liveness — and `icon`, `listening` and `primaryActions` are the tile's resolved view.","parameters":[{"name":"roomId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.RoomsController.get","x-alyt-scope":"tenant:read"},"patch":{"operationId":"roomsUpdate","summary":"Rename a room, set its icon, its quiet hours, its listening switch or its tile actions","tags":["Rooms"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoomsListItem"}}}},"403":{"description":"The caller cannot manage that property"}},"x-alyt-service":"tenant-service","description":"Quiet hours are minutes from local midnight in the property's timezone, and the range may cross midnight — 22:00 to 07:00 is start 1320, end 420. `null` clears them. While a room is quiet nothing speaks in it and no update reboots a device there. `listening` is the other direction: false and no voice-capable device in the room listens for a wake word. `primaryActions` are the tile's two actions; `null` returns to the computed defaults.","parameters":[{"name":"roomId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":120},"quietHours":{"type":["object","null"],"properties":{"start":{"type":"integer","minimum":0,"maximum":1439},"end":{"type":"integer","minimum":0,"maximum":1439}},"required":["start","end"],"additionalProperties":false},"icon":{"type":["string","null"]},"listening":{"type":"boolean"},"primaryActions":{"type":["array","null"],"items":{"$ref":"#/components/schemas/RoomsListItemPrimaryActionsItem"},"maxItems":2}},"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.RoomsController.update","x-alyt-scope":"rooms:write"},"delete":{"operationId":"roomsRemove","summary":"Delete a room","tags":["Rooms"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"const":true}},"required":["deleted"],"additionalProperties":false}}}},"403":{"description":"The caller cannot manage that property"}},"x-alyt-service":"tenant-service","description":"Tenant staff, or the client's OWNER. The members are not deleted: every device and endpoint in the room stays in the house with `roomId` null, which unpairs them from each other, because being in the same room is the pairing. A device keeps its property.","parameters":[{"name":"roomId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.RoomsController.remove","x-alyt-scope":"rooms:write"}},"/v1/rooms/{roomId}/actions":{"post":{"operationId":"roomsRunAction","summary":"Run a room action on every member that reports the capability","tags":["Rooms"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropertiesRunActionResponse"}}}},"400":{"description":"Not an action this room offers, or a bad value"},"403":{"description":"The caller cannot control that property"}},"x-alyt-service":"tenant-service","description":"`{ capability, action, value }` from the room's `availableActions`. Fans out to every member reporting that capability, resolved at execution time. Answers per thing: `applied`, `skipped` (reports the capability but not this control) and `failed` with a reason each — never a single ok that hides a speaker that did not answer. `voice`/`listening` sets the room's listening flag, which its devices follow.","parameters":[{"name":"roomId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropertiesRunActionRequest"}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.RoomsController.runAction","x-alyt-scope":"rooms:write"}},"/v1/rooms/{roomId}/members":{"post":{"operationId":"roomsAddMember","summary":"Put a device or an endpoint in a room","tags":["Rooms"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"tenantId":{"type":"string","format":"uuid"},"propertyId":{"type":["string","null"],"format":"uuid"},"roomId":{"type":["string","null"],"format":"uuid"},"name":{"type":"string"},"kind":{"type":"string","enum":["VOICE_SATELLITE","HUB","SENSOR","AGENT","OTHER"]},"capabilities":{"type":"array","items":{"type":"string","enum":["VOICE","AUDIO_OUT","HUB","SENSOR","COFFEE_MACHINE","NETWORK","LIGHT","SWITCH","THERMOSTAT","LOCK","COVER","FAN","CAMERA","MEDIA_PLAYER","ENERGY","BUTTON","VACUUM","IRRIGATION","BATTERY"]}},"features":{"type":"array","items":{"type":"string"}},"currentUnitId":{"type":["string","null"],"format":"uuid"},"parentDeviceId":{"type":["string","null"],"format":"uuid"},"protocol":{"type":["string","null"],"enum":["MATTER","ZIGBEE","THREAD"]},"protocolId":{"type":["string","null"]},"firmwareChannel":{"type":"string","enum":["STABLE","BETA"]},"status":{"type":"string","enum":["PROVISIONING","ONLINE","OFFLINE","DISABLED","AWAITING_REPLACEMENT"]},"lastSeen":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["id","tenantId","propertyId","roomId","name","kind","capabilities","features","currentUnitId","parentDeviceId","protocol","protocolId","firmwareChannel","status","lastSeen","deletedAt","createdAt","updatedAt"],"additionalProperties":false},{"$ref":"#/components/schemas/EndpointsUpdateResponse"}]}}}},"403":{"description":"The caller cannot manage that property"}},"x-alyt-service":"tenant-service","description":"THIS IS THE PAIRING. A device and an endpoint that are both in a room are bound automatically; move the device to another room and its answers follow. There is no separate pair call to make.","parameters":[{"name":"roomId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"tenantId":{"type":"string","format":"uuid"},"kind":{"type":"string","enum":["device","endpoint"]},"id":{"type":"string","format":"uuid"}},"required":["tenantId","kind","id"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.RoomsController.addMember","x-alyt-scope":"rooms:write"}},"/v1/rooms/{roomId}/members/{kind}/{memberId}":{"delete":{"operationId":"roomsRemoveMember","summary":"Take a device or an endpoint out of a room, unbinding it","tags":["Rooms"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"removed":{"const":true}},"required":["removed"],"additionalProperties":false}}}},"403":{"description":"The caller cannot manage that property"},"404":{"description":"That member is not in this room"}},"x-alyt-service":"tenant-service","description":"Tenant staff, or the client's OWNER. `kind` is `device` or `endpoint`. Sets the member's `roomId` to null, which unpairs it from everything else in the room; a device keeps its property. A member that is not in this room is a 404.","parameters":[{"name":"roomId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","required":true,"schema":{"type":"string","enum":["device","endpoint"]}},{"name":"memberId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.RoomsController.removeMember","x-alyt-scope":"rooms:write"}},"/v1/integrations/catalogue":{"get":{"operationId":"integrationsCatalogue","summary":"The integration catalogue: every connector a home can connect, described","tags":["Integrations"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"schemaVersion":{"type":"integer"},"items":{"type":"array","items":{"type":"object","properties":{"kind":{"type":"string","pattern":"^[A-Z0-9_]{2,40}$"},"name":{"type":"string","minLength":1},"icon":{"type":"string","pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"},"promise":{"type":"string","minLength":1},"connectivity":{"type":"string","enum":["local","cloud","cloud-then-local"]},"brings":{"type":"array","items":{"type":"string","enum":["light","switch","thermostat","lock","cover","fan","camera","speaker","vacuum","irrigation","hub","voice","energy","button","sensor","appliance","other"]}},"connect":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptorConnect"},"discovery":{"type":["object","null"],"properties":{"verb":{"type":"string","minLength":1}},"required":["verb"],"additionalProperties":false},"disconnect":{"type":"object","properties":{"consequence":{"type":"string","minLength":1}},"required":["consequence"],"additionalProperties":false},"docsUrl":{"type":"string","format":"uri"},"pairing":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptorPairing"},"builtIn":{"type":"object","properties":{"reason":{"type":"string","minLength":1}},"required":["reason"],"additionalProperties":false},"requires":{"type":"array","items":{"type":"string"}},"author":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptorAuthor"},"version":{"type":"string","minLength":1},"source":{"type":"string","enum":["built-in","installed"]},"visibility":{"type":"string","enum":["public","tenant"]},"verification":{"$ref":"#/components/schemas/CatalogueDefinitionsItemDescriptorVerification"},"listed":{"type":"boolean"},"availability":{"type":"string","enum":["available","coming"]}},"required":["kind","name","icon","promise","connectivity","brings","connect","discovery","disconnect","author","version","source","visibility","verification","listed","availability"],"additionalProperties":false}}},"required":["schemaVersion","items"],"additionalProperties":false}}}}},"x-alyt-service":"tenant-service","description":"One entry per integration kind (plans/PLAN_2026-09-03_INTEGRATION_DESCRIPTORS.md): name, icon, the promise, whether it depends on a cloud, what it brings, HOW a person connects it (one of the closed set discover, form, signIn, oauth, pair, with the fields to render), whether it can look for things, what stops when it is disconnected, who made it, and how much of its claim has been proven. `availability` says whether a connector drives the kind on this platform today; a `coming` entry is shown, never connectable. Served from the published definitions (a PRIVATE one to its own tenant only), so a client caches it and renders whatever it is sent, and a new connector reaches the console and the phone with a refresh and no release.","security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.IntegrationsController.catalogue","x-alyt-scope":null}},"/v1/integrations":{"get":{"operationId":"integrationsList","summary":"List integrations, tenant-wide or for one client","tags":["Integrations"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"tenantId":{"type":"string","format":"uuid"},"clientId":{"type":["string","null"],"format":"uuid"},"kind":{"type":"string","pattern":"^[A-Z0-9_]{2,40}$"},"name":{"type":"string"},"status":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"things":{"type":"integer"},"lastSeen":{"type":["string","null"],"format":"date-time"}},"required":["id","tenantId","clientId","kind","name","status","createdAt","updatedAt","things","lastSeen"],"additionalProperties":false}},"nextCursor":{"type":["string","null"]}},"required":["items","nextCursor"],"additionalProperties":false}}}},"403":{"description":"The caller has no access to that scope"}},"x-alyt-service":"tenant-service","description":"Omitting clientId asks for the tenant-wide list, which is a staff view. A client user must name their client so the scope can be checked against their membership. A client's list first makes sure every built-in kind (a radio on the hub, `builtIn` on the descriptor) exists for that client when its connector reports the hardware present, so what is part of the hub is there before anyone connects anything.","parameters":[{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"clientId","in":"query","required":false,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.IntegrationsController.list","x-alyt-scope":"integrations:read"},"post":{"operationId":"integrationsConnect","summary":"Connect an integration from what the person typed, or answer its challenge","tags":["Integrations"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/IntegrationsConnectOption1"},{"$ref":"#/components/schemas/IntegrationsConnectOption2"},{"$ref":"#/components/schemas/IntegrationsConnectOption3"}]}}}},"400":{"description":"A kind a home cannot connect, a required field missing, or nothing drives that kind yet"},"403":{"description":"The caller cannot configure that home"},"502":{"description":"The connector or the provider did not answer"}},"x-alyt-service":"tenant-service","description":"Generic for every kind in the catalogue. What the person typed is `input`, keyed by the descriptor's fields; a `discover` connector takes none. The kind's connector validates with a real call to the provider before anything is stored and answers `connected` (the integration exists; with a `propertyId` its things were discovered and placed in rooms named after the provider's), `challenge` (the provider wants one more thing, such as an emailed code: the client re-posts with `challenge.token` and the answer), or `rejected` with the reason. Secrets go to the connector and into `config`, never back out. Connecting is configuring the home: the client's OWNER, or tenant staff for a tenant-wide integration.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"tenantId":{"type":"string","format":"uuid"},"clientId":{"type":"string","format":"uuid"},"propertyId":{"type":"string","format":"uuid"},"kind":{"type":"string","pattern":"^[A-Z0-9_]{2,40}$"},"name":{"type":"string","minLength":1,"maxLength":120},"input":{"type":"object","additionalProperties":{"type":"string"}},"challenge":{"$ref":"#/components/schemas/IntegrationsConnectChallenge"}},"required":["tenantId","kind"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.IntegrationsController.connect","x-alyt-scope":"integrations:write"}},"/v1/integrations/{integrationId}/discover":{"post":{"operationId":"integrationsDiscover","summary":"Look for this integration's things on the network and place the new ones","tags":["Integrations"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"found":{"type":"integer"},"endpoints":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"roomId":{"type":["string","null"],"format":"uuid"}},"required":["id","name","roomId"],"additionalProperties":false}},"note":{"type":"string"},"joinOpenSeconds":{"type":"integer","minimum":0},"pending":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1},"reason":{"type":"string","minLength":1}},"required":["name","reason"],"additionalProperties":false}}},"required":["found","endpoints"],"additionalProperties":false}}}},"400":{"description":"This integration does not look for things"},"403":{"description":"The caller cannot configure that home"},"502":{"description":"The connector did not answer"}},"x-alyt-service":"tenant-service","description":"For any integration whose descriptor declares `discovery`. The connector looks, syncs what it found as endpoints (keyed by the provider's stable id, never an address), and with a `propertyId` puts each new one in the room of its name, creating the room if missing and never moving one somebody placed. Answers how many were found and where they are. Configure right: the client's OWNER, or tenant staff.","parameters":[{"name":"integrationId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"tenantId":{"type":"string","format":"uuid"},"propertyId":{"type":"string","format":"uuid"}},"required":["tenantId"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.IntegrationsController.discover","x-alyt-scope":"integrations:write"}},"/v1/integrations/{integrationId}/actions":{"post":{"operationId":"integrationsAction","summary":"Do one action a pairing guide declared, such as resetting a bulb by the code printed on it","tags":["Integrations"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["done","rejected"]},"message":{"type":"string","minLength":1}},"required":["status","message"],"additionalProperties":false}}}},"400":{"description":"Not an action this integration declares, or a field is missing"},"403":{"description":"The caller cannot configure that home"},"502":{"description":"The connector did not answer"}},"x-alyt-service":"tenant-service","description":"For an integration whose descriptor carries `pairing`: the guide's brands declare actions with fields, the client renders them, and this route performs one with the stored config. An action the descriptor does not declare is refused here, never forwarded. Answers `done` or `rejected` with a sentence for the person. Configure right: the client's OWNER, or tenant staff.","parameters":[{"name":"integrationId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"tenantId":{"type":"string","format":"uuid"},"action":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,40}$"},"input":{"type":"object","additionalProperties":{"type":"string"},"default":{}}},"required":["tenantId","action"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.IntegrationsController.action","x-alyt-scope":"integrations:write"}},"/v1/integrations/{integrationId}/supported":{"get":{"operationId":"integrationsSupported","summary":"The devices the integration's bridge supports for one brand of its pairing guide","tags":["Integrations"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"vendor":{"type":"string","minLength":1},"model":{"type":"string","minLength":1},"description":{"type":"string"},"exposes":{"type":"array","items":{"type":"string"}}},"required":["vendor","model","description","exposes"],"additionalProperties":false}},"nextCursor":{"type":["string","null"]}},"required":["items","nextCursor"],"additionalProperties":false}}}},"400":{"description":"No such brand in the guide"},"403":{"description":"The caller is not a member of that home"},"502":{"description":"The connector did not answer"}},"x-alyt-service":"tenant-service","description":"For an integration whose descriptor carries `pairing` with `supported`: the list the bridge itself ships (zigbee2mqtt's device definitions, at the version the bridge runs), filtered to the brand's vendor names, so what the client shows as supported is what will actually be read. The list envelope, one page. Empty for a brand the bridge does not list. Any member of the home may read it.","parameters":[{"name":"integrationId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"vendor","in":"query","required":true,"schema":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,40}$"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.IntegrationsController.supported","x-alyt-scope":"integrations:read"}},"/v1/integrations/{integrationId}":{"patch":{"operationId":"integrationsUpdate","summary":"Settings on a connected integration: rename it, or give it new credentials","tags":["Integrations"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/IntegrationsConnectOption1"},{"$ref":"#/components/schemas/IntegrationsConnectOption2"},{"$ref":"#/components/schemas/IntegrationsConnectOption3"}]}}}},"400":{"description":"A required field is missing, or this integration has no credentials to change"},"403":{"description":"The caller cannot configure that home"},"502":{"description":"The connector or the provider did not answer"}},"x-alyt-service":"tenant-service","description":"Every connector has its own settings, which are the same fields its connect screen asks (from the descriptor). New credentials are validated by the connector with a real call before the stored ones are replaced, and answer `connected`, `challenge` or `rejected` exactly as connecting does; a rejected change leaves the old credentials in place. Secrets never come back out. Configure right: the client's OWNER, or tenant staff.","parameters":[{"name":"integrationId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"tenantId":{"type":"string","format":"uuid"},"name":{"type":"string","minLength":1,"maxLength":120},"input":{"type":"object","additionalProperties":{"type":"string"}},"challenge":{"$ref":"#/components/schemas/IntegrationsConnectChallenge"}},"required":["tenantId"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.IntegrationsController.update","x-alyt-scope":"integrations:write"},"delete":{"operationId":"integrationsDisconnect","summary":"Disconnect an integration, and say what stopped with it","tags":["Integrations"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"const":true},"things":{"type":"integer"},"rooms":{"type":"array","items":{"type":"string"}}},"required":["deleted","things","rooms"],"additionalProperties":false}}}},"403":{"description":"The caller cannot configure that home"},"404":{"description":"No such integration in that tenant"}},"x-alyt-service":"tenant-service","description":"The integration and every endpoint it brought are removed (the database cascades); the rooms keep their other members. Answers how many things went and which rooms they were in, which is the sentence the client showed before asking. Its secrets go with it. Configure right: the client's OWNER, or tenant staff.","parameters":[{"name":"integrationId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.IntegrationsController.disconnect","x-alyt-scope":"integrations:write"}},"/v1/integrations/{integrationId}/endpoints":{"get":{"operationId":"integrationsListEndpoints","summary":"List an integration's endpoints","tags":["Integrations"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IntegrationsListEndpointsResponse"}}}},"403":{"description":"The caller has no access to that integration"}},"x-alyt-service":"tenant-service","description":"Read through the integration: tenant staff or the client's members when it belongs to a client, tenant staff only when it is tenant-wide. Answers the endpoint rows by name as a bare array, not the list envelope. Each row carries the provider's stable `externalId`, the cached `address`, the connector's `capabilities` detail, `capabilitySet`, `features` and the last reported `state`; nothing from the integration's `config` is ever included.","parameters":[{"name":"integrationId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.IntegrationsController.listEndpoints","x-alyt-scope":"integrations:read"},"post":{"operationId":"integrationsSyncEndpoints","summary":"Replace an integration's endpoints with a freshly discovered set","tags":["Integrations"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IntegrationsListEndpointsResponse"}}}},"403":{"description":"The caller cannot write to that integration"}},"x-alyt-service":"tenant-service","description":"How discovery lands: the connector discovers zones and syncs them here. Room assignments already made are preserved.","parameters":[{"name":"integrationId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"tenantId":{"type":"string","format":"uuid"},"propertyId":{"type":"string","format":"uuid"},"endpoints":{"type":"array","items":{"type":"object","properties":{"externalId":{"type":"string","minLength":1,"maxLength":200},"name":{"type":"string","minLength":1,"maxLength":120},"kind":{"type":"string","enum":["SPEAKER","LIGHT","SENSOR","OTHER"]},"address":{"type":"string","maxLength":200},"capabilities":{"type":"object","additionalProperties":{}},"capabilitySet":{"type":"array","items":{"type":"string","enum":["VOICE","AUDIO_OUT","HUB","SENSOR","COFFEE_MACHINE","NETWORK","LIGHT","SWITCH","THERMOSTAT","LOCK","COVER","FAN","CAMERA","MEDIA_PLAYER","ENERGY","BUTTON","VACUUM","IRRIGATION","BATTERY"]},"maxItems":16},"features":{"type":"array","items":{"type":"string","minLength":1,"maxLength":64},"maxItems":32},"roomName":{"type":"string","minLength":1,"maxLength":120}},"required":["externalId","name","kind"],"additionalProperties":false},"maxItems":500}},"required":["tenantId","endpoints"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.IntegrationsController.syncEndpoints","x-alyt-scope":"integrations:write"}},"/v1/endpoints/{endpointId}/snapshot":{"get":{"operationId":"endpointsSnapshot","summary":"The camera's stored picture","tags":["Integrations"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"string","description":"image bytes"}}}},"403":{"description":"The caller cannot view that property"},"404":{"description":"No such endpoint, or the provider keeps no picture"}},"x-alyt-service":"tenant-service","description":"Anyone who can view the property. The provider's last stored picture of the camera (Ring keeps one per motion, or on the interval chosen in its app), served as the image itself. Never a live frame: a live frame wakes a battery camera (docs/MEDIA.md). Reached by the console as `/media/<endpoint id>/snapshot.jpeg`, which the gateway maps here.","parameters":[{"name":"endpointId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.EndpointsController.snapshot","x-alyt-scope":"things:read"}},"/v1/endpoints/{endpointId}/commands":{"post":{"operationId":"endpointsCommand","summary":"Change an endpoint: set a state control or press a momentary one","tags":["Integrations"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DevicesListItemThing"}}}},"400":{"description":"Not a control this endpoint offers, a bad value, or an integration nothing drives yet"},"403":{"description":"The caller cannot control that endpoint"},"502":{"description":"The connector or the device did not answer"}},"x-alyt-service":"tenant-service","description":"The envelope `POST /devices/:id/commands` shares: `{ command: \"set\" | \"press\", payload: { capability, key, value } }`. Validated against the endpoint's own descriptors before anything is sent (a 400 names the reason), performed by the integration's connector, and answered with the thing as the connector READ IT BACK. Anyone who can see the house may control it.","parameters":[{"name":"endpointId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"command":{"type":"string","enum":["set","press"]},"payload":{"$ref":"#/components/schemas/DevicesCommandOption1Payload"},"tenantId":{"type":"string","format":"uuid"}},"required":["command","payload","tenantId"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.EndpointsController.command","x-alyt-scope":"things:control"}},"/v1/endpoints/{endpointId}/refresh":{"post":{"operationId":"endpointsRefresh","summary":"Ask the connector what this endpoint is doing now, store it, and return the thing","tags":["Integrations"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DevicesListItemThing"}}}},"400":{"description":"The connector could not reach it"},"403":{"description":"The caller cannot view that endpoint"}},"x-alyt-service":"tenant-service","description":"Anyone who can view the endpoint: through the room's property when it is in a room, else through the integration's client, and tenant staff only when the integration is tenant-wide. The integration's connector is asked for the endpoint's current state, which is stored with `stateUpdatedAt`, and the thing is answered as it now reads. A connector that could not reach it is a 400 and nothing is stored.","parameters":[{"name":"endpointId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.EndpointsController.refresh","x-alyt-scope":"things:read"}},"/v1/endpoints/{endpointId}":{"get":{"operationId":"endpointsGet","summary":"One endpoint, described as a thing","tags":["Integrations"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"allOf":[{"type":"object","additionalProperties":{}},{"type":"object","properties":{"thing":{"$ref":"#/components/schemas/DevicesListItemThing"}},"required":["thing"],"additionalProperties":false}]}}}},"403":{"description":"The caller cannot view that endpoint"},"404":{"description":"No such endpoint in that tenant"}},"x-alyt-service":"tenant-service","description":"The endpoint row plus `thing`: its capabilities bound to control primitives, the live state its connector last reported, and liveness (null until a connector has polled it — unpolled is not offline). Read through the room's property when it is in a room, else through the integration's client.","parameters":[{"name":"endpointId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.EndpointsController.get","x-alyt-scope":"things:read"},"patch":{"operationId":"endpointsUpdate","summary":"Update an endpoint: which room it is in, and what it is called","tags":["Integrations"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EndpointsUpdateResponse"}}}},"403":{"description":"The caller cannot manage the property that owns the room"}},"x-alyt-service":"tenant-service","description":"Assigning an endpoint to a room is what binds it to the devices in that room — there is no pairing call. Setting roomId to null unassigns it, and authorisation is then checked against the room it is leaving; an endpoint in no room is checked against its integration's client. `name` is the person's name for it: a WiZ light arrives as \"WiZ light FC84\" and is renamed here.","parameters":[{"name":"endpointId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"tenantId":{"type":"string","format":"uuid"},"roomId":{"type":["string","null"],"format":"uuid"},"name":{"type":"string","minLength":1,"maxLength":120},"presentedAs":{"type":["string","null"],"enum":["light","switch"]}},"required":["tenantId"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.EndpointsController.update","x-alyt-scope":"things:read"}},"/v1/properties/{propertyId}/routines":{"get":{"operationId":"routinesList","summary":"List the routines of a home","tags":["Properties"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RoutinesListItem"}},"nextCursor":{"type":["string","null"]}},"required":["items","nextCursor"],"additionalProperties":false}}}},"403":{"description":"The caller cannot view that property"}},"x-alyt-service":"tenant-service","description":"Anyone who can view the property. Paged by name. Each routine carries its triggers (`when`, any of them), conditions (`only`, all of them), and ordered steps (`do`), as `docs/ROUTINES.md` defines them, plus when it last ran and how often.","parameters":[{"name":"propertyId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string","maxLength":200}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.RoutinesController.list","x-alyt-scope":"tenant:read"},"post":{"operationId":"routinesCreate","summary":"Create a routine","tags":["Properties"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoutinesListItem"}}}},"400":{"description":"A step names something not in this home"},"403":{"description":"The caller cannot manage that property"},"409":{"description":"A routine with that name exists"}},"x-alyt-service":"tenant-service","description":"Tenant staff, or the client's OWNER. `when` lists the triggers (a phrase, a time, the sun, a thing's state, presence, or manual), `only` the conditions that must all hold, `do` the steps in order. A room or routine named in a step must be in this home, or the write is refused with the step number. The name is unique in the home.","parameters":[{"name":"propertyId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":80},"icon":{"type":["string","null"],"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"},"enabled":{"type":"boolean"},"when":{"type":"array","items":{"anyOf":[{"$ref":"#/components/schemas/RoutinesListItemWhenItemOption1"},{"$ref":"#/components/schemas/RoutinesListItemWhenItemOption2"},{"$ref":"#/components/schemas/RoutinesListItemWhenItemOption3"},{"$ref":"#/components/schemas/RoutinesListItemWhenItemOption4"},{"$ref":"#/components/schemas/RoutinesListItemWhenItemOption5"},{"type":"object","properties":{"kind":{"const":"manual"}},"required":["kind"],"additionalProperties":false,"description":"A button on the console, the phone or the skill: `POST /routines/:id/run`."}]},"maxItems":10,"description":"Any one starts it (OR). Empty means only by hand: Run always works."},"only":{"type":"array","items":{"anyOf":[{"$ref":"#/components/schemas/RoutinesListItemOnlyItemOption1"},{"$ref":"#/components/schemas/RoutinesListItemOnlyItemOption2"},{"$ref":"#/components/schemas/RoutinesListItemOnlyItemOption3"},{"$ref":"#/components/schemas/RoutinesListItemOnlyItemOption4"},{"$ref":"#/components/schemas/RoutinesListItemOnlyItemOption5"}]},"maxItems":10},"do":{"type":"array","items":{"anyOf":[{"$ref":"#/components/schemas/RoutinesListItemDoItemOption1"},{"$ref":"#/components/schemas/RoutinesListItemDoItemOption2"},{"$ref":"#/components/schemas/RoutinesListItemDoItemOption3"},{"$ref":"#/components/schemas/RoutinesListItemDoItemOption4"},{"$ref":"#/components/schemas/RoutinesListItemDoItemOption5"},{"$ref":"#/components/schemas/RoutinesListItemDoItemOption6"}]},"minItems":1,"maxItems":30},"tenantId":{"type":"string","format":"uuid"}},"required":["name","when","do","tenantId"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.RoutinesController.create","x-alyt-scope":"rooms:write"}},"/v1/properties/{propertyId}/weather":{"get":{"operationId":"weatherReport","summary":"The weather at the home","tags":["Properties"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"propertyId":{"type":"string","format":"uuid"},"units":{"type":"string","enum":["metric","imperial"]},"at":{"type":"string","format":"date-time"},"timezone":{"type":"string"},"localTime":{"type":"string"},"address":{"type":["string","null"]},"current":{"type":"object","properties":{"temperature":{"type":"number"},"feelsLike":{"type":["number","null"]},"humidity":{"type":["integer","null"]},"windSpeed":{"type":["number","null"]},"code":{"type":"integer"},"word":{"type":"string"},"icon":{"type":"string"},"isDay":{"type":"boolean"}},"required":["temperature","feelsLike","humidity","windSpeed","code","word","icon","isDay"],"additionalProperties":false},"today":{"type":"object","properties":{"high":{"type":"number"},"low":{"type":"number"},"precipitationChance":{"type":["integer","null"],"minimum":0,"maximum":100},"sunrise":{"type":["string","null"],"format":"date-time"},"sunset":{"type":["string","null"],"format":"date-time"}},"required":["high","low","precipitationChance","sunrise","sunset"],"additionalProperties":false},"hours":{"type":"array","items":{"type":"object","properties":{"at":{"type":"string","format":"date-time"},"temperature":{"type":"number"},"code":{"type":"integer"},"word":{"type":"string"},"precipitationChance":{"type":["integer","null"],"minimum":0,"maximum":100},"isDay":{"type":"boolean"}},"required":["at","temperature","code","word","precipitationChance","isDay"],"additionalProperties":false}},"provider":{"type":"string"}},"required":["propertyId","units","at","timezone","localTime","address","current","today","hours","provider"],"additionalProperties":false}}}},"400":{"description":"The weather service did not answer"},"403":{"description":"The caller cannot view that property"},"404":{"description":"No such property, or it has no address"}},"x-alyt-service":"tenant-service","description":"Anyone who can view the property. Answered from the home's coordinates, which come from the address saved on the property; without one it is a 404 with the reason, and the widget shows how to add it. Current conditions in words, today's high and low, the next twelve hours, sunrise and sunset, and the home's local time. Cached fifteen minutes per home. `units` defaults to the home's: Fahrenheit in the US zones.","parameters":[{"name":"propertyId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"units","in":"query","required":false,"schema":{"type":"string","enum":["metric","imperial"]}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"tenant-service.WeatherController.report","x-alyt-scope":"tenant:read"}},"/v1/ai/chat":{"post":{"operationId":"aiChat","summary":"Chat completion, routed local-first","tags":["Voice and AI"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"reply":{"type":"string","description":"The assistant's text. Empty when the model returned no content."},"privacyTier":{"type":"string","enum":["home","lan","cloud"],"description":"Which tier served the request: the property's own runner, the operator's LAN, or a cloud provider the tenant opted into."},"routedBecause":{"type":"string","description":"Why that tier was chosen, in a sentence."},"model":{"type":"string","description":"The model name sent to the tier."},"latencyMs":{"type":"integer","description":"Wall-clock time of the upstream call."}},"required":["reply","privacyTier","routedBecause","model","latencyMs"],"additionalProperties":false}}}},"400":{"description":"Malformed request, or an unknown tenant"},"503":{"description":"No node could serve the request; cloud is refused unless the tenant has opted in"}},"x-alyt-service":"ai-gateway","description":"There is deliberately NO cloudOptIn field in the request. Whether a tenant's reasoning may leave the property is read from the tenant record, because a caller able to set it per request could send a customer's conversation to the cloud — the one thing this product promises never happens. The response records which node served the request.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"tenantId":{"type":"string","minLength":1},"messages":{"type":"array","items":{"type":"object","properties":{"role":{"type":"string"},"content":{"type":"string"}},"required":["role","content"],"additionalProperties":false},"minItems":1},"model":{"type":"string"},"homeRunnerUrl":{"type":"string","format":"uri"},"maxTokens":{"type":"integer","minimum":0,"maximum":4096}},"required":["tenantId","messages"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"ai-gateway.AiController.chat","x-alyt-scope":"ai:chat"}},"/v1/voice/turns":{"get":{"operationId":"voiceTurns","summary":"What the assistant heard, newest first","tags":["Voice and AI"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"at":{"type":"string","format":"date-time"},"deviceId":{"type":["string","null"]},"roomName":{"type":["string","null"]},"wakeWord":{"type":["string","null"]},"persona":{"type":["string","null"]},"transcript":{"type":["string","null"]},"reply":{"type":["string","null"]},"transcriptStored":{"type":"boolean","description":"Whether words were kept for this turn."},"stages":{"type":["object","null"],"additionalProperties":{"type":"object","properties":{"node":{"type":"string","maxLength":120},"cloud":{"type":"boolean"},"ms":{"type":"integer","minimum":0}},"additionalProperties":false}},"cloudUsed":{"type":"boolean","description":"True if ANY stage left the property."},"totalMs":{"type":["integer","null"]},"outcome":{"type":"string","description":"`ok`, or the reason the turn failed."}},"required":["id","at","deviceId","roomName","wakeWord","persona","transcript","reply","transcriptStored","stages","cloudUsed","totalMs","outcome"],"additionalProperties":false}},"nextCursor":{"type":["string","null"]}},"required":["items","nextCursor"],"additionalProperties":false}}}},"403":{"description":"No access to that tenant's history"}},"x-alyt-service":"ai-gateway","description":"`transcript` is null unless the tenant has opted into storing words, and `transcriptStored` says which of the two it is — null means NOT STORED, never that the person was silent. Every turn carries its per-stage provenance.","parameters":[{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant whose history is being read."}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string","maxLength":200}},{"name":"deviceId","in":"query","required":false,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"ai-gateway.VoiceController.turns","x-alyt-scope":"telemetry:read"}},"/v1/voice/personas":{"get":{"operationId":"voicePersonas","summary":"The personas that can answer, and how each sounds","tags":["Voice and AI"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"voice":{"type":"string","description":"How it sounds, in words, for a UI."},"voiceId":{"type":"string","description":"The synthesiser voice it is rendered with."},"summary":{"type":"string"},"tools":{"type":"boolean","description":"Whether it can look things up or act on the house."},"languagePolicy":{"type":"string","enum":["english-only","english-by-default"]},"languages":{"type":"string","description":"The policy as a sentence, so a UI need not translate the enum."}},"required":["id","name","voice","voiceId","summary","tools","languagePolicy","languages"],"additionalProperties":false}},"nextCursor":{"type":["string","null"]}},"required":["items","nextCursor"],"additionalProperties":false}}}}},"x-alyt-service":"ai-gateway","description":"The platform owns this catalogue; the Python voice stack holds matching constants and a test reads those files and fails if they disagree, because two copies of a fact drift. `languagePolicy` is not decoration: Simone is English-only and her speech-to-text language is pinned accordingly, which is what stops a one-second clip being mis-detected as Spanish. Pino answers in another language only when clearly addressed in it.\n\nNot tenant-scoped: the personas are a property of the product, not of a customer. Which WAKE WORD selects which persona is per device, and lives in the device's settings under the voice capability.\n\nReturns the standard list envelope like every other collection, even though this one is fixed and tiny: a client must not have to know which collections can grow.","security":[{"bearerAuth":[]}],"x-alyt-handler":"ai-gateway.VoiceController.personas","x-alyt-scope":"telemetry:read"}},"/v1/voice/stats":{"get":{"operationId":"voiceStats","summary":"Turn counts, latency and the share that never left the property","tags":["Voice and AI"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"windowDays":{"type":"integer"},"since":{"type":"string","format":"date-time","description":"Start of the window."},"turns":{"type":"integer","description":"Turns in the window."},"perDay":{"type":"array","items":{"type":"object","properties":{"date":{"type":"string","description":"YYYY-MM-DD"},"turns":{"type":"integer"}},"required":["date","turns"],"additionalProperties":false},"description":"One bucket per day in the window, including days with no turns."},"localSharePercent":{"type":["integer","null"]},"failed":{"type":"integer","description":"Turns whose outcome was not `ok`."},"medianMs":{"type":["integer","null"]},"p95Ms":{"type":["integer","null"]},"byPersona":{"type":"array","items":{"type":"object","properties":{"persona":{"type":"string","description":"`unknown` for turns that recorded no persona."},"count":{"type":"integer"}},"required":["persona","count"],"additionalProperties":false}}},"required":["windowDays","since","turns","perDay","localSharePercent","failed","medianMs","p95Ms","byPersona"],"additionalProperties":false}}}},"400":{"description":"days outside 1..90"},"403":{"description":"No access to that tenant's history"}},"x-alyt-service":"ai-gateway","description":"`localSharePercent` is the product's thesis as a number, computed from the turns themselves. It is null when there are no turns — a fresh property has proven nothing, and showing 100% for zero turns would be a lie.","parameters":[{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant whose history is being read."}},{"name":"days","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":90,"default":7}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"ai-gateway.VoiceController.stats","x-alyt-scope":"telemetry:read"}},"/v1/notifications/channels":{"get":{"operationId":"channelsList","summary":"The notification channels a client must create, and what each is for","tags":["Notifications"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"channels":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","enum":["security","device_health","device_activity","household","product"],"description":"The permanent id. Create the channel with exactly this."},"name":{"type":"string","description":"What the person sees in the system settings list."},"importance":{"type":"string","enum":["HIGH","DEFAULT","LOW","MIN"]},"sound":{"type":"boolean","description":"Whether it makes a sound. Immutable on Android once created."},"interruptionLevel":{"type":"string","enum":["timeSensitive","active","passive"]},"why":{"type":"string","description":"Why this is its own channel."}},"required":["id","name","importance","sound","interruptionLevel","why"],"additionalProperties":false}},"kinds":{"type":"array","items":{"type":"object","properties":{"kind":{"type":"string","enum":["membership.granted","membership.revoked","device.transfer.requested","password.reset.requested","device.offline","device.awaiting_replacement","device.command.failed","rollout.failed","device.registered","device.recovered","device.firmware.updated","rollout.completed","client.invited","membership.expiring","routine.notice","routine.failed","product.announcement"]},"channelId":{"type":"string","enum":["security","device_health","device_activity","household","product"]}},"required":["kind","channelId"],"additionalProperties":false},"description":"Every notification the platform sends, and the channel it arrives on."}},"required":["channels","kinds"],"additionalProperties":false}}}}},"x-alyt-service":"notification-service","description":"Create exactly these, with exactly these ids and importances. They are a permanent contract: on Android a channel's importance and sound cannot be changed once it exists on a device, so a client that invents or renames one cannot be corrected later. `kinds` lists every notification the platform sends and the channel it arrives on, so a client can predict what it will receive.","security":[{"bearerAuth":[]}],"x-alyt-handler":"notification-service.ChannelsController.list","x-alyt-scope":"notifications:read"}},"/v1/notifications":{"get":{"operationId":"outboxList","summary":"Notifications for a tenant, newest first","tags":["Notifications"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"kind":{"type":"string","description":"A key of NOTIFICATION_KINDS, as stored when the notification was raised."},"channelId":{"type":"string","description":"The channel resolved when it was raised, as stored."},"channel":{"type":["object","null"],"properties":{"id":{"type":"string","enum":["security","device_health","device_activity","household","product"],"description":"The permanent id. Create the channel with exactly this."},"name":{"type":"string","description":"What the person sees in the system settings list."},"importance":{"type":"string","enum":["HIGH","DEFAULT","LOW","MIN"]},"sound":{"type":"boolean","description":"Whether it makes a sound. Immutable on Android once created."},"interruptionLevel":{"type":"string","enum":["timeSensitive","active","passive"]},"why":{"type":"string","description":"Why this is its own channel."}},"required":["id","name","importance","sound","interruptionLevel","why"],"additionalProperties":false},"deviceId":{"type":["string","null"]},"title":{"type":"string"},"body":{"type":"string"},"readAt":{"type":["string","null"],"format":"date-time"},"at":{"type":"string","format":"date-time","description":"When it was raised."}},"required":["id","kind","channelId","channel","deviceId","title","body","readAt","at"],"additionalProperties":false}},"nextCursor":{"type":["string","null"]}},"required":["items","nextCursor"],"additionalProperties":false}}}},"403":{"description":"The caller has no access to that tenant"}},"x-alyt-service":"notification-service","description":"Each carries the `channelId` it was routed to WHEN IT WAS RAISED, not one derived at read time — that is what guarantees a recovery can never end up on a channel that makes a sound. `unreadOnly` filters to those nobody has acknowledged.","parameters":[{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string","maxLength":200}},{"name":"unreadOnly","in":"query","required":false,"schema":{"type":"string","enum":["true","false"]}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"notification-service.OutboxController.list","x-alyt-scope":"notifications:read"}},"/v1/notifications/{id}/read":{"post":{"operationId":"outboxMarkRead","summary":"Mark one notification read","tags":["Notifications"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"readAt":{"type":["string","null"],"format":"date-time"}},"required":["id","readAt"],"additionalProperties":false}}}},"403":{"description":"The caller has no access to that tenant"},"404":{"description":"No such notification"}},"x-alyt-service":"notification-service","description":"Sets `readAt` to now on the notification, provided it belongs to the named tenant. Idempotent: one that is already read is answered with its existing `readAt`, not an error. Anyone with access to the tenant may do it, including a household user whose client belongs to the tenant, because the notification is about their own devices. Read state is per notification, not per person: one acknowledgement marks it read for everyone.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tenantId","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"The tenant the caller is acting on."}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"notification-service.OutboxController.markRead","x-alyt-scope":"notifications:write"}},"/v1/notifications/push-tokens":{"post":{"operationId":"pushTokensRegister","summary":"Register this device for push, or re-point an existing token","tags":["Notifications"],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"Use THIS to unregister. The provider token never appears in a URL."},"platform":{"type":"string","enum":["APNS","FCM"],"description":"Which provider this token belongs to: Apple's APNs or Google's FCM."},"registeredAt":{"type":"string","format":"date-time"},"lastSeenAt":{"type":"string","format":"date-time","description":"Refreshed every time the app registers again."}},"required":["id","platform","registeredAt","lastSeenAt"],"additionalProperties":false}}}},"401":{"description":"No session"}},"x-alyt-service":"notification-service","description":"Called by the app at every launch: it refreshes `lastSeenAt` and re-enables a token a provider had previously reported gone, because an app that is registering is by definition installed. Registering a token that already exists re-points it at the calling user instead of adding a row — the same handset changes hands, and the previous owner's notifications must not keep arriving on it. The token is stored, never returned and never logged; unregister by the `id` this answers with.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"type":"string","enum":["APNS","FCM"],"description":"Which provider this token belongs to: Apple's APNs or Google's FCM."},"token":{"type":"string","minLength":1,"maxLength":4096,"description":"The provider's token: an APNs device token or an FCM registration token. Registering a token that already exists RE-POINTS it at the calling user rather than adding a row — the same handset changes hands, and the previous owner's notifications must not keep arriving on it."},"deviceLabel":{"type":"string","maxLength":120},"appVersion":{"type":"string","maxLength":40},"locale":{"type":"string","maxLength":35}},"required":["platform","token"],"additionalProperties":false}}}},"security":[{"bearerAuth":[]}],"x-alyt-handler":"notification-service.PushTokensController.register","x-alyt-scope":"notifications:write"},"get":{"operationId":"pushTokensList","summary":"The devices this account receives push on","tags":["Notifications"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"platform":{"type":"string","enum":["APNS","FCM"],"description":"Which provider this token belongs to: Apple's APNs or Google's FCM."},"deviceLabel":{"type":["string","null"]},"appVersion":{"type":["string","null"]},"locale":{"type":["string","null"]},"registeredAt":{"type":"string","format":"date-time"},"lastSeenAt":{"type":"string","format":"date-time"},"active":{"type":"boolean","description":"False once the provider reported the install is gone. Shown rather than hidden: 'this phone stopped receiving' is the question this screen answers."}},"required":["id","platform","deviceLabel","appVersion","locale","registeredAt","lastSeenAt","active"],"additionalProperties":false}}},"required":["items"],"additionalProperties":false}}}},"401":{"description":"No session"}},"x-alyt-service":"notification-service","description":"For a 'your devices' settings screen. The provider token is never included. `active` is false once the provider reported the install is gone, which is shown rather than hidden because 'this phone stopped receiving' is what the screen is for.","security":[{"bearerAuth":[]}],"x-alyt-handler":"notification-service.PushTokensController.list","x-alyt-scope":"notifications:read"}},"/v1/notifications/push-tokens/{id}":{"delete":{"operationId":"pushTokensUnregister","summary":"Stop pushing to one device","tags":["Notifications"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"removed":{"const":true}},"required":["id","removed"],"additionalProperties":false}}}},"401":{"description":"No session"},"404":{"description":"No such device token for this account"}},"x-alyt-service":"notification-service","description":"For a sign-out, or a person removing a phone they no longer have. Addressed by the id this service issued, never by the provider token, which would then appear in access logs. Scoped to the caller's own devices: another account's id is a 404, never a way to silence their phone.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"security":[{"bearerAuth":[]}],"x-alyt-handler":"notification-service.PushTokensController.unregister","x-alyt-scope":"notifications:write"}}},"x-alyt-generated":"2026-09-05T06:17:32.913Z","x-alyt-services":["ai-gateway","auth-service","developer-service","device-service","device-simulator","notification-service","tenant-service"],"x-alyt-undocumented-routes":[],"x-alyt-internal-routes":["POST /auth/introspect","POST /auth/revoke-user","POST /integrations/{integrationId}/endpoints/{externalId}/state","POST /notifications/email","POST /notifications/password-reset"],"tags":[{"name":"Devices","description":"Devices, their units, capabilities, state and commands; pairing tokens."},{"name":"Firmware","description":"Firmware channels, rollouts and a device's current build."},{"name":"Rooms","description":"Rooms as the home is laid out: members, tile actions, suggestions."},{"name":"Properties","description":"Properties within a tenant: the house, its overview and actions."},{"name":"Integrations","description":"Connectors installed in a tenant, their endpoints, discovery and control."},{"name":"Notifications","description":"Notifications raised for a tenant and the channels that deliver them."},{"name":"Tenants","description":"Tenants, their members and settings; a sandbox is one."},{"name":"Voice and AI","description":"Voice turns, personas and the inference route."},{"name":"Catalogue","description":"The connector catalogue and the capability descriptors, public."},{"name":"Developer account","description":"Sign-up, verification, sign-in, sessions and the profile of a developer."},{"name":"API keys and usage","description":"Keys, their scopes and rotation; the quota and this month's usage."},{"name":"Events and webhooks","description":"Webhook endpoints, deliveries, signatures and the event stream."},{"name":"Simulator","description":"Virtual devices in the sandbox and the scenarios they run."},{"name":"Submissions","description":"A connector from draft to published: versions, submission, review outcome."},{"name":"Review (staff)","description":"The review queue and developer approval; ALYT staff sessions only."},{"name":"Platform sessions (staff)","description":"The platform's own sign-in, used by ALYT staff on developers.alyt.com."}],"x-alyt-scopes":{"tenant:read":"the sandbox tenant, its clients, properties and rooms","rooms:write":"create, rename and delete rooms and properties","devices:read":"devices, units, status, settings and liveness","devices:write":"device settings, transfers and pairing tokens","devices:commands":"send a command to a device","telemetry:read":"telemetry and voice statistics","things:read":"endpoints and their state","things:control":"control an endpoint","integrations:read":"the catalogue and what is connected","integrations:write":"connect, discover and disconnect integrations","notifications:read":"the notification list and channels","notifications:write":"mark notifications read and set preferences","ai:chat":"ALYT's own inference, on a granted key only","simulator:write":"create virtual devices and run scenarios","webhooks:write":"register webhook endpoints and read their deliveries","events:read":"the event stream","firmware:read":"firmware channels and versions"}}