Skip to content

Commit 6796909

Browse files
committed
feat(hosted-web): add authorized workspace registry
1 parent f603743 commit 6796909

11 files changed

Lines changed: 1558 additions & 0 deletions

File tree

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
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+
}

src/features/workspace-registry/contracts/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './hosted-workspace-registry';
12
export {
23
MAX_WORKSPACE_ALLOWED_OPERATIONS,
34
MAX_WORKSPACE_REGISTRATIONS,

0 commit comments

Comments
 (0)