Devices

Device identity beside API keys — pairing code, short-lived access token, refresh rotation, replay detection. First-class, hash-only storage, both boot paths.

An API key is long-lived and shown once — right for a CI pipeline, wrong for a credential that sits on hardware somebody else holds. A device is a machine caller with a tenant whose credential is short-lived and rotated: an admin pairs it, the device exchanges the pairing code for tokens, and from then on it rotates. Like API keys it is a framework feature, not a plugin: the _voltro_devices table is framework-internal and stores hashes only, and one devices: true in app.config.ts turns on verification and the routes, under voltro dev and voltro serve alike.

Enable

// app.config.ts
export default {
  type: 'api' as const, name: 'api',
  devices: true,   // or { prefix?: 'dev_', managementPath?: '/v1/devices', accessTtlSeconds?: 900, refreshTtlSeconds?: 2_592_000, pairingTtlSeconds?: 600 }
}

The four steps

Method + path Who calls Action
POST /v1/devices/pair an admin (admin:full or devices:manage) create the device in the caller's tenant — returns the pairing code once
POST /v1/devices/exchange the device, once, no session pairing code → accessToken + refreshToken; the code is consumed
POST /v1/devices/rotate the device, repeatedly, no session refreshToken → a new pair, generation + 1
GET /v1/devices · POST /v1/devices/revoke an admin list the tenant's devices (no secrets) · revoke by id
# 1. An admin pairs a kiosk (the pairing code is shown ONCE, valid 10 minutes by default):
curl -XPOST https://api.example.com/v1/devices/pair \
  -H 'Authorization: Bearer <admin-session>' \
  -d '{"name":"lobby-kiosk","scopes":["kiosk:read"]}'
#   → { "id": "device_…", "pairingCode": "devp_…", "pairingExpiresAt": 1700000600000 }

# 2. The device exchanges it — no session, the code IS the credential:
curl -XPOST https://api.example.com/v1/devices/exchange -d '{"pairingCode":"devp_…"}'
#   → { "deviceId": "device_…", "accessToken": "dev_…", "accessExpiresAt": …, "refreshToken": "devr_…", "refreshExpiresAt": …, "generation": 1 }

# 3. The device calls the API with the access token (15 minutes by default):
curl https://api.example.com/... -H 'Authorization: Bearer dev_…'

# 4. Before it expires, the device rotates:
curl -XPOST https://api.example.com/v1/devices/rotate -d '{"refreshToken":"devr_…"}'
#   → a new pair, generation 2; the previous access token is dead

What holds

  • A device is a serviceAccount subject with id = <device id>, the pairer's tenantId, the paired scopes, and metadata.device = { name, generation }. Handlers, requireScope and tenant scoping treat it like any other subject; its writes are attributed to its own actors row.
  • Bounded by the token. The subject carries credentialExpiresAt = accessExpiresAt, so a subscription opened with an access token ends when the token does and the device's next rotation re-opens it.
  • A replayed refresh token revokes the device. Rotation moves the presented refresh token to "rotated out". Presenting it again means two holders have the same token, and one of them is not the device — the device is revoked with reason refresh-replay, and its current pair with it. Revocation is 401 on the next call; there is no grace.
  • One answer for every refusal. exchange and rotate return 401 with one reason for unknown, expired, already-used, revoked and replayed alike, so neither route can be used to probe.
  • A device may not exceed its pairer. Like API-key issuance, the paired scopes must be held by the caller (an admin:full caller may pair anything). A caller without a tenant cannot pair: a device lives in a tenant.
  • No per-request write. lastSeenAt is stamped on exchange and rotation — which the access lifetime forces at least every accessTtlSeconds — never on a call.

In-handler service

Build your own pairing UI on the same DeviceService:

import { makeDeviceService, dataStoreDeviceStore } from '@voltro/runtime'

const devices = makeDeviceService(dataStoreDeviceStore(ctx.store), { accessTtlSeconds: 300 })
const paired = await devices.pair({ tenantId, name: 'gate-3', scopes: ['gate:open'], createdBy: ctx.subject.id })
// show paired.pairingCode ONCE; later: devices.list(tenantId), devices.revoke(id, tenantId)

exchange, rotate and resolveAccess are what the routes and the strategy call; frameworkDeviceStrategy(devices) is the strategy itself, should you mount the routes elsewhere.