Skip to content

Grant schema

A "grant" is what pairing produces. Cloud-side it's the IHostLinkRecord; on the wire to the Device it's the DeviceSetupExchangeResponse. Both shapes appear in the docs-devices Reference; this page restates them from the security perspective and adds the scope-normalization rule.

IHostLinkRecord (cloud authority)

Source: projects/matrix-3/packages/system-auth/src/host-auth.ts:94-113.

ts
interface IHostLinkRecord {
  readonly id: string;                       // hostlink_<24 hex>
  readonly hostId: string;                   // host_<20 hex>
  readonly hostName?: string;                // mutable label
  readonly deviceSlug: string;               // address-safe projection key
  readonly natsUserPublicKeys?: readonly string[];
  heartbeatTokenHash?: string;               // sha256 of the bearer
  heartbeatTokenIssuedAt?: string;
  readonly principalId: string;
  readonly spaceId: string;
  readonly routeKey?: string;
  readonly publicNamespace?: string;
  readonly authorityRoot: string;
  readonly scopes: readonly string[];
  status: 'active' | 'revoked';
  readonly createdAt: string;
  updatedAt: string;
  lastRefreshedAt?: string;
  revokedAt?: string;
}

DeviceSetupExchangeResponse (wire)

ts
interface DeviceSetupExchangeResponse {
  ok: true;
  authorityRoot: string;
  deviceRegistryRoot: string;
  hostLinkId?: string;
  hostId?: string;
  deviceId?: string;        // == hostId
  hostName?: string;
  deviceSlug?: string;
  principalId?: string;
  routeKey?: string;
  publicNamespace?: string;
  spaceId?: string;
  credentials: Record<string, string>;
  nats: {
    url: string;
    wsUrl?: string;
  };
}

credentials keys present today:

KeyValue
natsUserJwtdevice-scoped NATS user JWT
natsUserSeednkey seed
hostLinkTokenplaintext heartbeat bearer (returned exactly once)
registryToken(optional) device registry write token

Scope normalization

scopes[] on the Host Link record is sorted, de-duplicated, with empty entries dropped. From host-auth.ts:1775-1784:

ts
function normalizeScopes(value: readonly string[] | undefined): readonly string[] {
  const clean = new Set<string>();
  for (const entry of value ?? []) {
    const scope = readNonEmptyString(entry);
    if (scope) clean.add(scope);
  }
  return [...clean].sort();
}

So:

input:  ['invoke', 'invoke', '', 'install', 'invoke']
output: ['install', 'invoke']

This canonicalization is applied at HostLinkStore.create() and during pending device-link / pairing record construction.

Note: the scopes field is recorded for audit but not used as a per-op gate today. See Capability tokens.

Pending grant records

Pairing records that have not yet been exchanged carry their own shapes:

IHostDeviceLinkRecord

ts
interface IHostDeviceLinkRecord {
  readonly deviceCode: string;
  readonly userCode: string;
  readonly hostId?: string;
  readonly hostName?: string;
  readonly routeKey?: string;
  readonly scopes: readonly string[];
  readonly intervalSeconds: number;
  readonly createdAt: string;
  readonly expiresAt: string;
  status: 'pending' | 'approved' | 'namespace_required' | 'exchanged' | 'cancelled';
  updatedAt: string;
  error?: string;
  approvedAt?: string;
  approvedByPrincipalId?: string;
  hostLinkId?: string;
  exchangedAt?: string;
  cancelledAt?: string;
}

IHostPairingRecord

ts
interface IHostPairingRecord {
  readonly pairRequestId: string;
  readonly hostId?: string;
  readonly hostName?: string;
  readonly localReturnUrl?: string;
  readonly nonce?: string;
  readonly routeKey?: string;
  readonly scopes: readonly string[];
  readonly createdAt: string;
  readonly expiresAt: string;
  status: 'pending' | 'approved' | 'exchanged' | 'cancelled';
  updatedAt: string;
  approvalCode?: string;
  approvedAt?: string;
  approvedByPrincipalId?: string;
  hostLinkId?: string;
  exchangedAt?: string;
  cancelledAt?: string;
}

The IHostDeviceLinkRecord is for headless device-code flow; IHostPairingRecord is for interactive browser-pair. Both terminate in a Host Link record.

Identity-fields summary

FieldFormatGenerator
principalIdp_<20 hex>stablePrincipalId(issuer, subject)
spaceIdspace_<20 hex>generateSpaceId()
hostIdhost_<20 hex>generateHostId()
hostLinkIdhostlink_<24 hex>generateHostLinkId()
deviceCodebase64url, 32 random bytesgenerateDeviceCode()
userCodeXXXX-XXXX (32-char alphabet)generateDeviceUserCode()
pairRequestIdpair_<24 hex>generatePairRequestId()
approvalCodebase64url, 24 bytesgeneratePairApprovalCode()
hostLinkTokenhlink_<base64url 32 bytes>generateHostLinkHeartbeatToken()
deviceSlugkebab-lowercase, ≤64 charsdeviceSlugFromLabel() + allocateDeviceSlug()

See also

Source: projects/matrix-3/packages/system-auth/src/host-auth.ts (IHostLinkRecord, IHostDeviceLinkRecord, IHostPairingRecord, normalizeScopes, identity generators); WORKSTREAMS/product-launch/HIVECAST-DEVICE-ENROLLMENT-SPEC.md § "Setup-exchange / device redeem response".