1const NODE_ID_PATTERN = /^[0-9a-f]{64}$/;2const GRANTS = Object.freeze(['view', 'pointer_keyboard', 'gamepad']);34export function shortPeerFingerprint(nodeId) {5 if (typeof nodeId !== 'string' || !NODE_ID_PATTERN.test(nodeId)) {6 throw new TypeError('node ID must be 64 lowercase hexadecimal characters');7 }8 return `${nodeId.slice(0, 8)}…${nodeId.slice(-8)}`;9}1011export function normalizeInvitationSummary(value, nowUnix = Math.floor(Date.now() / 1000)) {12 if (!value || typeof value !== 'object' || Array.isArray(value)) {13 throw new TypeError('invitation summary must be an object');14 }15 const allowed = new Set(['host_node_id', 'peer_node_id', 'expires_at_unix', 'grants']);16 for (const key of Object.keys(value)) {17 if (!allowed.has(key)) throw new TypeError(`unknown invitation summary field: ${key}`);18 }19 if (!NODE_ID_PATTERN.test(value.host_node_id || '')) throw new TypeError('invalid host node ID');20 if (!NODE_ID_PATTERN.test(value.peer_node_id || '')) throw new TypeError('invalid peer node ID');21 if (!Number.isSafeInteger(value.expires_at_unix) || value.expires_at_unix <= nowUnix) {22 throw new TypeError('invitation is expired or has an invalid expiry');23 }24 if (!Array.isArray(value.grants) || value.grants.length === 0 || value.grants.length > GRANTS.length) {25 throw new TypeError('invitation grants must be a non-empty bounded list');26 }27 const grants = [];28 for (const grant of value.grants) {29 if (!GRANTS.includes(grant) || grants.includes(grant)) {30 throw new TypeError('invitation contains an unknown or duplicate grant');31 }32 grants.push(grant);33 }34 return Object.freeze({35 hostNodeId: value.host_node_id,36 peerNodeId: value.peer_node_id,37 hostFingerprint: shortPeerFingerprint(value.host_node_id),38 peerFingerprint: shortPeerFingerprint(value.peer_node_id),39 expiresAtUnix: value.expires_at_unix,40 grants: Object.freeze(grants),41 });42}4344export function grantLabel(grant) {45 switch (grant) {46 case 'view': return 'view stream';47 case 'pointer_keyboard': return 'keyboard + mouse';48 case 'gamepad': return 'gamepad';49 default: throw new TypeError(`unknown invitation grant: ${grant}`);50 }51}5253export function formatInvitationExpiry(expiresAtUnix, nowUnix = Math.floor(Date.now() / 1000)) {54 if (!Number.isSafeInteger(expiresAtUnix) || !Number.isSafeInteger(nowUnix)) {55 throw new TypeError('expiry values must be integer Unix seconds');56 }57 const remaining = expiresAtUnix - nowUnix;58 if (remaining <= 0) return 'expired';59 if (remaining < 120) return 'expires in under 2 minutes';60 const minutes = Math.ceil(remaining / 60);61 return `expires in ${minutes} minutes`;62}