import type { BridgeCommandType } from './bridge-contract.ts' export const RNX_CLOUD_MAX_ARTIFACT_BYTES = 20 * 1024 * 1024 export const RNX_CLOUD_DESIGN_MAX_STORAGE_ENTRIES = 256 export const RNX_CLOUD_DESIGN_MAX_STORAGE_BYTES = 1024 * 1024 export const RNX_CLOUD_TEST_FILES_MAX_COUNT = 256 export const RNX_CLOUD_TEST_FILES_MAX_BYTES = 500_000 export type RnxCloudCommandScope = | 'read' | 'drive' | 'reset' | 'media' | 'debug' | 'watch' const RNX_CLOUD_COMMAND_SCOPE_SET = new Set([ 'read', 'drive', 'reset', 'media', 'debug', 'watch', ]) export function isRnxCloudCommandScope(value: string): value is RnxCloudCommandScope { return RNX_CLOUD_COMMAND_SCOPE_SET.has(value) } // scoped simulator tokens: one simulator, one verb set, one expiry, one // origin list. minted by the full per-simulator token or the account api key, // never by another scoped token, and never carrying debug. export const RNX_CLOUD_SCOPED_TOKEN_PREFIX = 'sk_rnx_sim_' export const RNX_CLOUD_SCOPED_TOKEN_DEFAULT_TTL_SECONDS = 3600 export const RNX_CLOUD_SCOPED_TOKEN_MIN_TTL_SECONDS = 1 export const RNX_CLOUD_SCOPED_TOKEN_MAX_TTL_SECONDS = 14400 export const RNX_CLOUD_SCOPED_TOKEN_MAX_ORIGINS = 16 export interface RnxCloudScopedTokenMint { scopes: RnxCloudCommandScope[] ttlSeconds?: number origins?: string[] } export interface RnxCloudScopedToken { tokenId: string token: string scopes: RnxCloudCommandScope[] expiresAt: number origins: string[] } // one scope table for both remote planes: every bridge verb names the scope // that may send it, so the nano service and the box relay admit the same // vocabulary and differ only by caller scope. reads observe, drive moves, // reset wipes, media injects camera fixtures, and debug evaluates. export const RNX_CLOUD_COMMAND_SCOPES = { state: 'read', tree: 'read', query: 'read', resolve: 'read', screenshot: 'read', capture: 'read', captureRegions: 'read', memory: 'read', diagnostics: 'read', storageSnapshot: 'read', perform: 'drive', waitFor: 'drive', tap: 'drive', longPress: 'drive', settle: 'drive', openUrl: 'drive', setAppearance: 'drive', keyboard: 'drive', focus: 'drive', flowStatus: 'drive', reset: 'reset', camera: 'media', // string evaluation and arbitrary bridge calls are maestro's boundary: the // customer sdk never carries this scope, and the service grants it only to // the account api key caller. close rides along, since ending the page ends // the sim and belongs to the sim's owner rather than any driver. evaluate: 'debug', call: 'debug', close: 'debug', } satisfies Record function scopeTableKeys(): BridgeCommandType[] { return Object.keys(RNX_CLOUD_COMMAND_SCOPES).filter((key): key is BridgeCommandType => Object.prototype.hasOwnProperty.call(RNX_CLOUD_COMMAND_SCOPES, key), ) } export const RNX_CLOUD_COMMAND_TYPES = Object.freeze(scopeTableKeys()) const RNX_CLOUD_COMMAND_TYPE_SET = new Set(RNX_CLOUD_COMMAND_TYPES) export function isRnxCloudCommandType( value: string, ): value is (typeof RNX_CLOUD_COMMAND_TYPES)[number] { return RNX_CLOUD_COMMAND_TYPE_SET.has(value) } export function rnxCloudCommandScope(type: BridgeCommandType): RnxCloudCommandScope { return RNX_CLOUD_COMMAND_SCOPES[type] } // what the full per-simulator token and the box token carry until scoped // tokens exist: every scope except debug. export const RNX_CLOUD_TOKEN_SCOPES: readonly RnxCloudCommandScope[] = Object.freeze([ 'read', 'drive', 'reset', 'media', ]) // every scope, granted only to the account api key caller through the account // service. the only credential the services accept for it today is the shared // service key, which only the account service holds. export const RNX_CLOUD_ALL_COMMAND_SCOPES: readonly RnxCloudCommandScope[] = Object.freeze(['read', 'drive', 'reset', 'media', 'debug']) export function rnxCloudScopesAllowCommand( scopes: readonly RnxCloudCommandScope[], type: BridgeCommandType, ): boolean { return scopes.includes(RNX_CLOUD_COMMAND_SCOPES[type]) } export interface RnxCloudClaim { id: string expiresAt: number } export interface RnxCloudCreateReceipt { simId: string artifact: { id: `sha256:${string}` bytes: number } claim: RnxCloudClaim } // a nano box is created around one artifact with its first simulator export interface RnxCloudBoxCreateReceipt { boxId: string size: 'nano' artifact: { id: `sha256:${string}` bytes: number } simulator: RnxCloudCreateReceipt } // what a cloud simulator reports. the stored lifecycle values are // `creating`/`open`/`hibernated`/`deleting`; `deleted` is only ever a response, // answered by a delete, because a deleted simulator has no row left to read. export type RnxCloudSimStatus = | 'creating' | 'open' | 'hibernated' | 'deleting' | 'deleted' // one simulator as the instance list reports it: the lifecycle view plus the // labels it was created with. export interface RnxCloudSimView { simId: string status: RnxCloudSimStatus labels: Record artifact: { sha256: string; bytes: number } claim: RnxCloudClaim | null createdAt: number lastActiveAt: number lastHibernatedAt?: number storageFrom?: string /** the idle window the creator set, or null for the service default. */ inactivityTimeoutMs: number | null /** the instant the creator's hard timeout stops this simulator, or null. */ hardTimeoutAt: number | null } // one simulator as create and get report it, with the watch URL a browser // opens to see it. export interface RnxCloudSimInstance extends RnxCloudSimView { /** a watch URL with its grant in the fragment, or null when none can be * minted yet: an unconfirmed simulator is not watchable. */ streamUrl: string | null } // the create/reuse answer: the receipt the box path also returns, plus what // only the instance API knows. export interface RnxCloudSimCreateResponse extends RnxCloudCreateReceipt { token: string status: RnxCloudSimStatus labels: Record streamUrl: string | null } // one grammar for the create map and the list selector: `key=value,key2=value2`. // a value may be empty; a key may not; a repeated key is the caller's mistake // rather than a silent last-one-wins. commas and control characters never // belong in a key or value, and 63 characters is the ceiling for either. export const RNX_CLOUD_LABELS_MAX_COUNT = 16 export const RNX_CLOUD_LABEL_MAX_LENGTH = 63 const RNX_CLOUD_LABEL_CONTROL = /[\u0000-\u001f\u007f]/ export function isValidRnxCloudLabel(key: string, value: string): boolean { return ( key.length > 0 && key.length <= RNX_CLOUD_LABEL_MAX_LENGTH && value.length <= RNX_CLOUD_LABEL_MAX_LENGTH && !key.includes(',') && !key.includes('=') && !value.includes(',') && !RNX_CLOUD_LABEL_CONTROL.test(key) && !RNX_CLOUD_LABEL_CONTROL.test(value) ) } /** the wire form, or null when it is not one. `''` is the empty map. */ export function parseRnxCloudLabels(value: string): Record | null { if (value === '') return {} const entries = value.split(',') if (entries.length > RNX_CLOUD_LABELS_MAX_COUNT) return null const labels: Record = {} for (const entry of entries) { const separator = entry.indexOf('=') if (separator < 1) return null const key = entry.slice(0, separator).trim() const label = entry.slice(separator + 1).trim() if (!isValidRnxCloudLabel(key, label)) return null if (Object.hasOwn(labels, key)) return null labels[key] = label } return labels } /** the same grammar with the map already split apart, as a json body carries * it. anything the wire form would refuse is refused here too. */ export function readRnxCloudLabels(value: unknown): Record | null { if (typeof value !== 'object' || value === null || Array.isArray(value)) return null const entries = Object.entries(value) if (entries.length > RNX_CLOUD_LABELS_MAX_COUNT) return null const labels: Record = {} for (const [key, label] of entries) { if (typeof label !== 'string') return null if (!isValidRnxCloudLabel(key, label)) return null labels[key] = label } return labels } /** every selector entry must be present with the same value. an empty * selector selects everything. */ export function matchesRnxCloudLabels( labels: Record, selector: Record, ): boolean { return Object.entries(selector).every(([key, value]) => labels[key] === value) } /** the selector an instance list filters by, given the statuses it accepts. */ export function isRnxCloudSimStatus(value: string): value is RnxCloudSimStatus { return ( value === 'creating' || value === 'open' || value === 'hibernated' || value === 'deleting' || value === 'deleted' ) }