Optional Smart-Device Credentials
How IT Setup conditionally surfaces supplier credentials based on the smart devices present in a property, how an IT admin connects them after the fact, and the end-to-end recipe for adding a brand-new credential-backed device (Daikin Altherma and Aidoo are worked examples).
For credential generation internals (HMAC email/password derivation, seeds) see credential-management.md. For capability execution internals see smart-device-abstraction.md.
The Core Idea: Credentials Are Optional and Device-Driven
A property only needs credentials for the platforms whose devices it actually has. SmartThings + Yale are the baseline commissioning path; Hive, Daikin, Aidoo, and iParcelBox are optional and are only shown/connected when a matching device is in the property's inventory.
There are two complementary "optional credential" primitives, at two layers:
- Presence flags (IT Setup / web) — the
it_setuprow carries per-service status (daikin_connected,yale_email,hive_email, …). The web UI shows a service's credential panel only when the property has a device of that type and the service isn't yet connected. Option-typed credentials (smart layer) —DeviceCredentialsincrates/bf_smart/src/rpc.rs:59holds every platform's creds asOption<…>withhas_*()guards. A capability simply won't route to a platform whose credentials are absent.
// crates/bf_smart/src/rpc.rs:79
impl DeviceCredentials {
pub fn has_yale(&self) -> bool {
self.yale_api_key.is_some() && self.yale_oauth_token.is_some()
}
pub fn has_hive(&self) -> bool { self.hive_api_key.is_some() }
pub fn has_daikin(&self) -> bool { self.daikin_oauth_token.is_some() }
pub fn has_aidoo(&self) -> bool { self.aidoo_token.is_some() }
pub fn has_parcelbox(&self) -> bool {
// org-wide API key + the per-device password must both be present
self.parcelbox_api_key.is_some() && self.parcelbox_device_password.is_some()
}
}
OAuth vs. Generated Credentials
Not every credential type needs OAuth. There are two families, and a new type can be either:
| Service | Auth model | Where the secret lives |
|---|---|---|
| SmartThings | Per-property API key (+ generated Samsung account) | it_setup.smartthings_api_key_encrypted (D1, encrypted) |
| Hive | Generated email + deterministic password — no OAuth | derived on demand from BF_CREDENTIAL_SEED_* |
| Yale | OAuth 2.0 | tokens in oauth_tokens (bf_auth DO); client creds are env secrets |
| Daikin | OAuth 2.0 | tokens in oauth_tokens (bf_auth DO); client creds per-property in it_setup |
| Aidoo | Direct Airzone email/password login | it_setup.aidoo_password_encrypted (D1, encrypted); tokens in bf_auth after login |
| iParcelBox | Org API key + per-device password — no OAuth | PARCELBOX_API_KEY env secret (fleet-wide); per-device parcelbox_device_password_encrypted on external_device_id (D1, encrypted) |
- Generated services (SmartThings/Hive) are "connected" the moment their account exists — there is no token round-trip to confirm.
- OAuth services (Yale/Daikin) are only "connected" after a token exchange succeeds and is verified (see Verifying Setup Status).
- Direct-login services (Aidoo) are only "connected" after a successful vendor login. Because an admin password exists, cloud transfer requires an Aidoo password-reset confirmation when Aidoo is connected.
- iParcelBox has no OAuth and no generated email. It is "connected" only after a provision call resolves the box's
DeviceID(MAC) and a livegetStatusconfirms the box is online (see Verifying Setup Status). The provision-resolved MAC and the encrypted device password are stored per-device; the API key is shared fleet-wide. - Future credential types that are pure API-key/username-password follow the Hive/SmartThings, Aidoo, or iParcelBox pattern and skip the OAuth handler entirely.
Vendor API Keys: Environment vs. Per-Property
Verified split — do not hard-code or push vendor keys per property unless the vendor's model requires it:
- Shared / global → Cloudflare Wrangler secrets (env), never in D1. Read via
env.secret(...):- Yale OAuth client + API key:
YALE_CLIENT_ID/YALE_CLIENT_SECRET/YALE_API_KEY(andYALE_STAGING_*), selected by environment incrates/bf_auth/src/yale.rs:19. - iParcelBox fleet credentials:
PARCELBOX_API_KEY(thex-api-keyon every cloud command, one per organisation) plus the master-accountPARCELBOX_ACCOUNT_USERNAME/PARCELBOX_ACCOUNT_PASSWORD(used only for the provision call).PARCELBOX_API_KEYis declared in the bf_user and bf_resident wranglers; the account creds are bf_resident-only (provisioning is setup-time). - Credential-generation seeds:
BF_CREDENTIAL_SEED_PROD/BF_CREDENTIAL_SEED_STAGING. - Encryption key:
BF_ENCRYPTION_KEY(reused to encrypt the per-device iParcelBox password). - Declared in
crates/bf_auth/wrangler.production.toml.
- Yale OAuth client + API key:
- Per-property / per-device → encrypted in D1. Used when each property/device carries its own secret:
- SmartThings API key →
it_setup.smartthings_api_key_encrypted. - Daikin OAuth client_id/secret →
daikin_client_id_encrypted/daikin_client_secret_encrypted(each property registers its own Onecta app). These are passed into the OAuth flow and re-used for autonomous token refresh. - Aidoo Airzone account password →
aidoo_password_encryptedonit_setup; the email is stored alongside it and bf_auth stores the resulting Airzone tokens after login. - iParcelBox per-device password →
external_device_id.parcelbox_device_password_encrypted(thex-device-passwordheader), alongside the resolvedparcelbox_device_id(MAC) andparcelbox_serial. Each physical box has its own password; the fleet API key is shared.
- SmartThings API key →
Rule of thumb: a key that is the same for the whole fleet is an env secret; a secret that a property's own vendor account or a specific physical device carries is encrypted in D1 (per-property in
it_setup, per-device onexternal_device_id).
Connecting Credentials After the Fact (Web, IT Admin)
Confirmed: an IT admin can add credentials on the property detail page at any point, not just during initial commissioning.
- Gating: all
/it-admin/*routes require theItAdmin(orAdmin) role, derived from the Clerk JWTit_adminfeature claim (crates/bf_resident/src/handlers/auth.rs:28). Enforced at the HTTP/middleware layer. - Conditional surfacing:
apps/web/src/lib/components/ITAdminManager.sveltederiveshasDaikinDevices,hasAidooDevices, andhasParcelBoxDevicesfrom the device list and shows the matching setup panel when the device is present and the service is not connected. - Store actions (
apps/web/src/stores/itSetup.ts):saveDaikinCredentials()→POST …/setup/save-daikin-credentialsrequestDaikinOAuthUrl()→POST …/setup/daikin-oauth-urlvalidateDaikin()→POST …/setup/validate-daikinsaveAidooCredentials()→POST …/setup/save-aidoo-credentialsconnectAidoo()→POST …/setup/connect-aidooprovisionParcelBox()→POST …/setup/provision-parcelbox
- The handler allows this in
HiveValidated,Commissioning, andCommissionedstates, and auto-syncs the credential to the property's User DO if the property is already (being) commissioned (handlers.rs:4757). - Secrets never reach the client. Handlers return
ItSetupClient(crates/bf_resident/src/handlers/it_setup/types.rs:37), which erases the SmartThings key, Daikin client_id/secret, and Aidoo password and exposes only presence flags (smartthings_api_key_configured,daikin_credentials_configured,aidoo_credentials_configured,*_connected). Always construct viaItSetupClient::from(it_setup).
Verifying Setup Status
"Is this service connected?" is answered differently per family:
- Generated (SmartThings/Hive): connected once the account/API key exists — presence is the status.
- OAuth (Yale/Daikin): connected only after a successful token exchange is confirmed. For Daikin (
handlers.rs:4703validate_daikin):- Require
it_setup.daikin_email(set when credentials were saved). - Ask bf_auth for token status:
BfAuthClient::get_token_status(daikin_email, env). - If
!status.has_tokens→400"complete the OAuth flow first". - Otherwise set
daikin_connected = true, write an audit log, and (if commissioned) sync to the User DO.
- Require
- The truth source for OAuth confirmation is bf_auth's
oauth_tokenstable, not theit_setupflag;daikin_connectedis a cached projection. Token refresh is service-aware — the bf_auth DO dispatches on theservicecolumn todaikin::refresh_token(per-property client creds) oryale::refresh_token(env creds) (crates/bf_auth/src/durable_object.rs:399). - Direct login (Aidoo):
connect_aidoorequires savedaidoo_email+ encryptedaidoo_password, asks bf_auth to log in to Airzone Cloud, and only then setsaidoo_connected = true. The password remains encrypted in D1; bf_auth stores the Airzone token material for runtime use and refresh. - Provision-resolved (iParcelBox): there are no OAuth tokens. The
provision_parcelboxhandler provisions the box (POST https://api.iparcelbox.com/provision), stores the returnedDeviceID(MAC) + encrypted device password + a generated webhook token, then does a livegetStatusto confirm the box is online. Only onceconnected:truedoes it register the webhook, disable the local API, and setit_setup.parcelbox_connected = true. If the box isn't on wifi yet it returns a "provisioned, waiting for box online" state and the step is re-runnable. Soparcelbox_connectedreflects a verified live box, not just stored creds.
Cloud Transfer Password Reset Policy
Cloud transfer is the cutover from installer/admin supplier access to the post-commissioning state. CompleteCloudTransferRequest therefore carries explicit reset confirmations for services where a reusable admin password exists:
- Yale: always required.
- Hive: required only when
it_setup.hive_emailexists. - Aidoo: required only when
it_setup.aidoo_connectedis true. - Daikin: no password-reset confirmation; OAuth token exchange is the confirmation path.
- iParcelBox: no password-reset confirmation; provisioning stores the per-device password, verifies live cloud status, and disables the local API as part of setup.
Optional confirmation fields default to false, so callers may omit Hive/Aidoo confirmations for properties that do not use those services.
Recipe: Add a New Credential Type
- Service enum — add a variant +
short_code()/name()+FromStrincrates/bf_auth/src/credentials.rs:69. - OAuth or generated?
- Generated: reuse
generate_email/generate_password_for_service(add a service-specific symbol set only if the vendor restricts characters, like Hive). - OAuth: add an exchange/refresh module (mirror
crates/bf_auth/src/daikin.rs), a callback arm incrates/bf_auth/src/handlers/oauth.rs, and a dispatch arm in the DO refresh (durable_object.rs:399). Add aservicevalue; theoauth_tokens.servicecolumn already supports multiple providers. - Direct login: mirror Aidoo — store the email/password encrypted in
it_setup, add a login endpoint in bf_auth, set*_connectedonly after the vendor login succeeds, and require a cloud-transfer password-reset confirmation if the account keeps a reusable admin password.
- Generated: reuse
- Key storage decision — shared key → Wrangler secret read via
env.secret(...); per-property key → new encrypted column onit_setup(+ migration), followingdaikin_client_*_encrypted. - Setup status — add a
*_connectedcolumn + email column toit_setup(migration), the DB getters/setters incrates/bf_resident_db/src/operations/it_setup.rs, and avalidate_*handler that confirms via bf_auth before flipping the flag. - Client view — extend
ItSetupClientwith presence flags only (*_configured,*_connected) and update theFrom<ItSetup>erasure. Mirror the fields in the webItSetupinterface (apps/web/src/stores/itSetup.ts). - Smart layer — add the
Optionfield +has_*()toDeviceCredentials(crates/bf_smart/src/rpc.rs:59), and populate it where the User DO builds credentials.
Recipe: Associate the Credential With a New Device
- Device type — add the variant to
crates/bf_types/src/smart/device_type.rs. Setsmartthings_capabilities()to&[]and exclude it fromrequires_smartthings_id()if it's vendor-API-only (asDaikinThermostatdoes). Add it toDeviceType::all()and any predicate (is_climate_device, etc.). - External ID — add the vendor id column to
devicesandexternal_device_id(migration, cf.daikin_id/daikin_type) so device resolution can attach it. - Conditional UI — the web decides "show this credential" by matching device type (
ITAdminManager.sveltechecksdevice_type === "daikin_thermostat").
Recipe: Make It Available for Smart-Device Actions
Capabilities route to a platform purely on type, then gate on has_*():
- Define the capability — add the variant to
DeviceCapabilityincrates/bf_smart/src/models.rs(e.g.SetDaikinRoomTemperature { temperature: f64 }). - Categorize — add a match arm in
categorize()(crates/bf_smart/src/capability/router.rs) mapping it to the platform category. - Guard + dispatch — in
crates/bf_smart/src/capability/mod.rs, the category arm checkscredentials.has_daikin()and the external id before calling the handler; absent creds → a clean error, never a panic. - Handler — implement the arm in
crates/bf_smart/src/capability/daikin.rs: call the vendor client (set_characteristic/get_gateway_devices), then return an optimistic updated state (PATCH succeeds → apply the set value to cached state, no extra GET).
Device State & Refresh
- State type —
DeviceStateData::DaikinThermostatincrates/bf_smart/src/models.rsholds the full snapshot (climate on/off, mode, room/setpoint temps, hot water,last_polled). - Refresh strategy — Daikin bypasses SmartThings. The scheduled
DeviceRefreshtask picksGetDaikinStateinstead ofRefreshforDaikinThermostat(crates/bf_user/src/durable_object/devices.rs:282), which does a singleGET /gateway-devicescovering all the property's Daikin devices. - Staleness — devices skipped if updated within the last 60s; budget-friendly (≈24 GETs/day at the hourly cadence, leaving headroom under Onecta's 200/day limit). See smart-device-abstraction.md.
Defining Events
- Event variant — add to
SmartEventincrates/bf_smart/src/models.rs(e.g.DaikinClimateChanged { … }). Use the full-stateStateUpdated { state }when returning a complete snapshot. - Apply to state — add a JSON-merge arm in
event_to_json_updates()(crates/bf_user/src/durable_object/state.rs). - Side effects — add an arm in
crates/bf_user/src/durable_object/notifications/side_effects.rs. State-only events (the Daikin ones) do nothing here; events that should notify a resident raise a notification. - Broadcast —
update_device_state_only()persists state (with a staleness guard) and pushes aDeviceStateUpdateWebSocket message to connected clients. See realtime-events.md.
Review Notes
One drift surfaced while documenting; it is not a bug today, but it is worth tightening:
- The device-type → "needs this credential" mapping lives only in the web frontend (
ITAdminManager.sveltestring-matches device types such as"daikin_thermostat","aidoo_thermostat", and"parcel_box"). There is no Rust-side source of truth, so backend and UI can disagree about which services a property requires. Consider aDeviceType::required_service()(or similar) inbf_typesthat both sides consume.
See Also
- credential-management.md — generation, seeds, OAuth token storage & refresh
- it-setup-flow.md — the commissioning state machine
- smart-device-abstraction.md — capability routing, state, optimistic updates
- realtime-events.md — how events reach clients