/** * Scoped hardwareId minting (TECH-1394). * * Peripheral hardwareIds are unique TENANT-WIDE on phyhub. An id that should * be pinned to a space or a device must carry that scope inside the id * itself — this module is the standard way to mint one. Using it is * optional: phyhub validates only charset and length * (`^[a-zA-Z0-9._:-]{3,100}$`), never structure, so hand-minted ids * (including ones with colons) remain first-class. What the helper buys is * an unambiguous, deterministic format: * * g: global — the id identifies the hardware itself * (vendor UUID, serial); the twin follows the * hardware anywhere in the tenant. * s-: space — survives device moves within the space, * recreates in another space. The spaceId is the * raw 24-hex ObjectId: readable and greppable. * d-: device — pinned to the device; a move mints a * new twin. deviceIds are UUIDs, so the segment * is the uuid with its dashes removed (32 hex) to * fit the 100-char server limit — still derivable * from the deviceId by stripping dashes. * * The user part gets a flat 64-char budget in EVERY scope (worst-case total: * 2+32+1+64 = 99 ≤ 100) and a charset of `[a-zA-Z0-9._-]` — the server set * MINUS the colon, which is reserved as the prefix separator so the * prefix/user boundary is never ambiguous. Everything is validated with * throws: no normalizing, no hashing, no truncation — an app with URL-length * or dirty identity inputs hashes them itself before calling (the * cameras-edge `sha256(rtspUrl).slice(0,16)` recipe). * * Deterministic by construction: same inputs always produce the same id. */ export type HardwareIdScope = 'global' | 'space' | 'device'; export const HARDWARE_ID_USER_PART_MAX_LENGTH = 64; const USER_PART_PATTERN = /^[a-zA-Z0-9._-]+$/; const SCOPE_SEGMENT_PATTERN = /^[a-zA-Z0-9._]{1,32}$/; /** * Pure formatter behind `instance.generateScopedHardwareId` — exported so the * format is unit-testable and usable where the scope id is already at hand. * * @param userId natural identity: zone name, vendor UID, hashed URL, … * @param scope where the id is unique; 'global' needs no scopeId * @param scopeId the spaceId ('space') or deviceId ('device'); dashes are * stripped (deviceIds are UUIDs), the rest must be 1–32 chars of * `[a-zA-Z0-9._]` */ export function formatScopedHardwareId(userId: string, scope: HardwareIdScope, scopeId?: string): string { const trimmedUserId = userId?.trim() ?? ''; if (trimmedUserId.length === 0) { throw new Error('generateScopedHardwareId: id must not be empty'); } if (trimmedUserId.length > HARDWARE_ID_USER_PART_MAX_LENGTH) { throw new Error( `generateScopedHardwareId: id exceeds ${HARDWARE_ID_USER_PART_MAX_LENGTH} chars (got ${trimmedUserId.length})`, ); } if (!USER_PART_PATTERN.test(trimmedUserId)) { throw new Error( `generateScopedHardwareId: id "${trimmedUserId}" contains characters outside [a-zA-Z0-9._-] ` + `(":" is reserved as the scope separator)`, ); } if (scope === 'global') { return `g:${trimmedUserId}`; } // Throws instead of falling back: a silently unscoped id would collide // tenant-wide, which is exactly what scoping exists to prevent. const scopeSegment = (scopeId ?? '').replace(/-/g, ''); if (!SCOPE_SEGMENT_PATTERN.test(scopeSegment)) { throw new Error( `generateScopedHardwareId: scope "${scope}" requires a valid ` + `${scope === 'space' ? 'spaceId' : 'deviceId'} — got "${scopeId}"`, ); } const scopeMarker = scope === 'space' ? 's' : 'd'; return `${scopeMarker}-${scopeSegment}:${trimmedUserId}`; }