|
| 1 | +import { |
| 2 | + type BootId, |
| 3 | + parseBootId, |
| 4 | + parseWorkspaceId, |
| 5 | + type WorkspaceId, |
| 6 | +} from '@shared/contracts/hosted'; |
| 7 | + |
| 8 | +import { |
| 9 | + MAX_WORKSPACE_REGISTRATIONS, |
| 10 | + parseMountGeneration, |
| 11 | + parseRegistrationRevision, |
| 12 | + type WorkspaceMountHealth, |
| 13 | +} from './workspace-registration'; |
| 14 | + |
| 15 | +export const HOSTED_WORKSPACE_REGISTRY_SCHEMA_VERSION = 1 as const; |
| 16 | +export const HOSTED_WORKSPACE_REGISTRY_ROUTES = Object.freeze({ |
| 17 | + list: '/api/hosted/v1/workspaces/list', |
| 18 | + select: '/api/hosted/v1/workspaces/select', |
| 19 | +} as const); |
| 20 | + |
| 21 | +export const HOSTED_WORKSPACE_CAPABILITIES = Object.freeze([ |
| 22 | + 'git.status.read', |
| 23 | + 'git.repository.initialize', |
| 24 | + 'git.initial-commit.create', |
| 25 | + 'git.branch.read', |
| 26 | + 'git.branch-tracking.update', |
| 27 | +] as const); |
| 28 | + |
| 29 | +export type HostedWorkspaceCapability = (typeof HOSTED_WORKSPACE_CAPABILITIES)[number]; |
| 30 | + |
| 31 | +export interface HostedWorkspaceRegistryListRequest { |
| 32 | + readonly schemaVersion: typeof HOSTED_WORKSPACE_REGISTRY_SCHEMA_VERSION; |
| 33 | +} |
| 34 | + |
| 35 | +export interface HostedWorkspaceRegistrySelectRequest extends HostedWorkspaceRegistryListRequest { |
| 36 | + readonly workspaceId: WorkspaceId; |
| 37 | +} |
| 38 | + |
| 39 | +export interface HostedWorkspaceMountDto { |
| 40 | + readonly bootId: BootId; |
| 41 | + readonly mountGeneration: number; |
| 42 | + readonly observedAt: number; |
| 43 | + readonly health: WorkspaceMountHealth; |
| 44 | + readonly capabilities: readonly HostedWorkspaceCapability[]; |
| 45 | +} |
| 46 | + |
| 47 | +export interface HostedWorkspaceDto { |
| 48 | + readonly workspaceId: WorkspaceId; |
| 49 | + /** Host-generated opaque UI label. Never sourced from a path or registration metadata. */ |
| 50 | + readonly label: string; |
| 51 | + readonly registrationRevision: number; |
| 52 | + readonly mount: HostedWorkspaceMountDto; |
| 53 | +} |
| 54 | + |
| 55 | +export interface HostedWorkspaceRegistryListResponse { |
| 56 | + readonly schemaVersion: typeof HOSTED_WORKSPACE_REGISTRY_SCHEMA_VERSION; |
| 57 | + readonly kind: 'workspace-list'; |
| 58 | + readonly workspaces: readonly HostedWorkspaceDto[]; |
| 59 | +} |
| 60 | + |
| 61 | +export interface HostedWorkspaceRegistrySelectResponse { |
| 62 | + readonly schemaVersion: typeof HOSTED_WORKSPACE_REGISTRY_SCHEMA_VERSION; |
| 63 | + readonly kind: 'workspace-selection'; |
| 64 | + readonly workspace: HostedWorkspaceDto; |
| 65 | +} |
| 66 | + |
| 67 | +export type HostedWorkspaceRegistryErrorCode = 'invalid_request' | 'not_found' | 'unavailable'; |
| 68 | + |
| 69 | +export interface HostedWorkspaceRegistryErrorResponse { |
| 70 | + readonly schemaVersion: typeof HOSTED_WORKSPACE_REGISTRY_SCHEMA_VERSION; |
| 71 | + readonly kind: 'error'; |
| 72 | + readonly code: HostedWorkspaceRegistryErrorCode; |
| 73 | +} |
| 74 | + |
| 75 | +const LIST_REQUEST_KEYS = ['schemaVersion'] as const; |
| 76 | +const SELECT_REQUEST_KEYS = ['schemaVersion', 'workspaceId'] as const; |
| 77 | +const LIST_RESPONSE_KEYS = ['schemaVersion', 'kind', 'workspaces'] as const; |
| 78 | +const SELECT_RESPONSE_KEYS = ['schemaVersion', 'kind', 'workspace'] as const; |
| 79 | +const WORKSPACE_KEYS = ['workspaceId', 'label', 'registrationRevision', 'mount'] as const; |
| 80 | +const MOUNT_KEYS = ['bootId', 'mountGeneration', 'observedAt', 'health', 'capabilities'] as const; |
| 81 | +const CAPABILITY_SET = new Set<string>(HOSTED_WORKSPACE_CAPABILITIES); |
| 82 | +const CAPABILITY_ORDER = new Map<string, number>( |
| 83 | + HOSTED_WORKSPACE_CAPABILITIES.map((capability, index) => [capability, index]) |
| 84 | +); |
| 85 | +const HEALTH_SET = new Set<string>(['healthy', 'read-only', 'unavailable']); |
| 86 | +const OPAQUE_LABEL_PATTERN = /^Workspace ([1-9][0-9]{0,2})$/; |
| 87 | +const READ_ONLY_CAPABILITIES = new Set<HostedWorkspaceCapability>([ |
| 88 | + 'git.status.read', |
| 89 | + 'git.branch.read', |
| 90 | +]); |
| 91 | + |
| 92 | +export function parseHostedWorkspaceRegistryListRequest( |
| 93 | + value: unknown |
| 94 | +): HostedWorkspaceRegistryListRequest { |
| 95 | + const input = exactRecord(value, LIST_REQUEST_KEYS, 'hosted-workspace-list-request-invalid'); |
| 96 | + assertSchemaVersion(input.schemaVersion); |
| 97 | + return Object.freeze({ schemaVersion: HOSTED_WORKSPACE_REGISTRY_SCHEMA_VERSION }); |
| 98 | +} |
| 99 | + |
| 100 | +export function parseHostedWorkspaceRegistrySelectRequest( |
| 101 | + value: unknown |
| 102 | +): HostedWorkspaceRegistrySelectRequest { |
| 103 | + const input = exactRecord(value, SELECT_REQUEST_KEYS, 'hosted-workspace-select-request-invalid'); |
| 104 | + assertSchemaVersion(input.schemaVersion); |
| 105 | + return Object.freeze({ |
| 106 | + schemaVersion: HOSTED_WORKSPACE_REGISTRY_SCHEMA_VERSION, |
| 107 | + workspaceId: parseWorkspaceId(input.workspaceId), |
| 108 | + }); |
| 109 | +} |
| 110 | + |
| 111 | +export function parseHostedWorkspaceRegistryListResponse( |
| 112 | + value: unknown |
| 113 | +): HostedWorkspaceRegistryListResponse { |
| 114 | + const input = exactRecord(value, LIST_RESPONSE_KEYS, 'hosted-workspace-list-response-invalid'); |
| 115 | + assertSchemaVersion(input.schemaVersion); |
| 116 | + if (input.kind !== 'workspace-list') invalid('hosted-workspace-list-response-invalid'); |
| 117 | + const workspaces = denseArray( |
| 118 | + input.workspaces, |
| 119 | + MAX_WORKSPACE_REGISTRATIONS, |
| 120 | + 'hosted-workspace-list-response-invalid' |
| 121 | + ).map(parseHostedWorkspaceDto); |
| 122 | + return Object.freeze({ |
| 123 | + schemaVersion: HOSTED_WORKSPACE_REGISTRY_SCHEMA_VERSION, |
| 124 | + kind: 'workspace-list', |
| 125 | + workspaces: Object.freeze(workspaces), |
| 126 | + }); |
| 127 | +} |
| 128 | + |
| 129 | +export function parseHostedWorkspaceRegistrySelectResponse( |
| 130 | + value: unknown |
| 131 | +): HostedWorkspaceRegistrySelectResponse { |
| 132 | + const input = exactRecord( |
| 133 | + value, |
| 134 | + SELECT_RESPONSE_KEYS, |
| 135 | + 'hosted-workspace-select-response-invalid' |
| 136 | + ); |
| 137 | + assertSchemaVersion(input.schemaVersion); |
| 138 | + if (input.kind !== 'workspace-selection') invalid('hosted-workspace-select-response-invalid'); |
| 139 | + return Object.freeze({ |
| 140 | + schemaVersion: HOSTED_WORKSPACE_REGISTRY_SCHEMA_VERSION, |
| 141 | + kind: 'workspace-selection', |
| 142 | + workspace: parseHostedWorkspaceDto(input.workspace), |
| 143 | + }); |
| 144 | +} |
| 145 | + |
| 146 | +function parseHostedWorkspaceDto(value: unknown): HostedWorkspaceDto { |
| 147 | + const input = exactRecord(value, WORKSPACE_KEYS, 'hosted-workspace-dto-invalid'); |
| 148 | + return Object.freeze({ |
| 149 | + workspaceId: parseWorkspaceId(input.workspaceId), |
| 150 | + label: parseOpaqueWorkspaceLabel(input.label), |
| 151 | + registrationRevision: parseRegistrationRevision(input.registrationRevision), |
| 152 | + mount: parseHostedWorkspaceMountDto(input.mount), |
| 153 | + }); |
| 154 | +} |
| 155 | + |
| 156 | +function parseHostedWorkspaceMountDto(value: unknown): HostedWorkspaceMountDto { |
| 157 | + const input = exactRecord(value, MOUNT_KEYS, 'hosted-workspace-mount-dto-invalid'); |
| 158 | + if (!Number.isSafeInteger(input.observedAt) || (input.observedAt as number) < 0) { |
| 159 | + invalid('hosted-workspace-mount-dto-invalid'); |
| 160 | + } |
| 161 | + if (typeof input.health !== 'string' || !HEALTH_SET.has(input.health)) { |
| 162 | + invalid('hosted-workspace-mount-dto-invalid'); |
| 163 | + } |
| 164 | + const capabilities = denseArray( |
| 165 | + input.capabilities, |
| 166 | + HOSTED_WORKSPACE_CAPABILITIES.length, |
| 167 | + 'hosted-workspace-mount-dto-invalid' |
| 168 | + ).map((capability) => { |
| 169 | + if (typeof capability !== 'string' || !CAPABILITY_SET.has(capability)) { |
| 170 | + return invalid('hosted-workspace-mount-dto-invalid'); |
| 171 | + } |
| 172 | + return capability as HostedWorkspaceCapability; |
| 173 | + }); |
| 174 | + if (new Set(capabilities).size !== capabilities.length) { |
| 175 | + invalid('hosted-workspace-mount-dto-invalid'); |
| 176 | + } |
| 177 | + if ( |
| 178 | + capabilities.some( |
| 179 | + (capability, index) => |
| 180 | + index > 0 && |
| 181 | + (CAPABILITY_ORDER.get(capabilities[index - 1] ?? '') ?? Number.MAX_SAFE_INTEGER) >= |
| 182 | + (CAPABILITY_ORDER.get(capability) ?? -1) |
| 183 | + ) || |
| 184 | + (input.health === 'unavailable' && capabilities.length !== 0) || |
| 185 | + (input.health === 'read-only' && |
| 186 | + capabilities.some((capability) => !READ_ONLY_CAPABILITIES.has(capability))) |
| 187 | + ) { |
| 188 | + invalid('hosted-workspace-mount-dto-invalid'); |
| 189 | + } |
| 190 | + return Object.freeze({ |
| 191 | + bootId: parseBootId(input.bootId), |
| 192 | + mountGeneration: parseMountGeneration(input.mountGeneration), |
| 193 | + observedAt: input.observedAt as number, |
| 194 | + health: input.health as WorkspaceMountHealth, |
| 195 | + capabilities: Object.freeze(capabilities), |
| 196 | + }); |
| 197 | +} |
| 198 | + |
| 199 | +function parseOpaqueWorkspaceLabel(value: unknown): string { |
| 200 | + if (typeof value !== 'string') { |
| 201 | + invalid('hosted-workspace-label-invalid'); |
| 202 | + } |
| 203 | + const match = OPAQUE_LABEL_PATTERN.exec(value); |
| 204 | + if (!match || Number(match[1]) > MAX_WORKSPACE_REGISTRATIONS) { |
| 205 | + invalid('hosted-workspace-label-invalid'); |
| 206 | + } |
| 207 | + return value; |
| 208 | +} |
| 209 | + |
| 210 | +function assertSchemaVersion(value: unknown): void { |
| 211 | + if (value !== HOSTED_WORKSPACE_REGISTRY_SCHEMA_VERSION) { |
| 212 | + invalid('hosted-workspace-registry-schema-version-invalid'); |
| 213 | + } |
| 214 | +} |
| 215 | + |
| 216 | +function exactRecord( |
| 217 | + value: unknown, |
| 218 | + expectedKeys: readonly string[], |
| 219 | + reason: string |
| 220 | +): Readonly<Record<string, unknown>> { |
| 221 | + if (!value || typeof value !== 'object' || Array.isArray(value)) invalid(reason); |
| 222 | + try { |
| 223 | + if (Object.getPrototypeOf(value) !== Object.prototype) invalid(reason); |
| 224 | + const keys = Reflect.ownKeys(value); |
| 225 | + if ( |
| 226 | + keys.length !== expectedKeys.length || |
| 227 | + keys.some((key) => typeof key !== 'string' || !expectedKeys.includes(key)) |
| 228 | + ) { |
| 229 | + invalid(reason); |
| 230 | + } |
| 231 | + const snapshot: Record<string, unknown> = {}; |
| 232 | + for (const key of expectedKeys) { |
| 233 | + const descriptor = Object.getOwnPropertyDescriptor(value, key); |
| 234 | + if (!descriptor || !('value' in descriptor)) invalid(reason); |
| 235 | + snapshot[key] = descriptor.value; |
| 236 | + } |
| 237 | + return Object.freeze(snapshot); |
| 238 | + } catch { |
| 239 | + return invalid(reason); |
| 240 | + } |
| 241 | +} |
| 242 | + |
| 243 | +function denseArray(value: unknown, limit: number, reason: string): readonly unknown[] { |
| 244 | + if (!Array.isArray(value) || value.length > limit) invalid(reason); |
| 245 | + const result: unknown[] = []; |
| 246 | + for (let index = 0; index < value.length; index += 1) { |
| 247 | + if (!Object.prototype.hasOwnProperty.call(value, index)) invalid(reason); |
| 248 | + result.push(value[index]); |
| 249 | + } |
| 250 | + return Object.freeze(result); |
| 251 | +} |
| 252 | + |
| 253 | +function invalid(reason: string): never { |
| 254 | + throw new TypeError(reason); |
| 255 | +} |
0 commit comments