@miosa/sdk 1.2.3 → 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,291 @@
1
+ import { HttpClient } from "../http.js";
2
+
3
+ export type DeviceKind =
4
+ | "sandbox_worker"
5
+ | "computer"
6
+ | "local_device"
7
+ | "docker_deploy_host";
8
+
9
+ export type DeviceSource = "sandboxes" | "computers";
10
+
11
+ export interface DeviceCatalogEntry {
12
+ kind: DeviceKind;
13
+ label: string;
14
+ purpose: string;
15
+ lifecycle: string;
16
+ persistence: string;
17
+ primaryCommands: string[];
18
+ useWhen: string[];
19
+ avoidWhen: string[];
20
+ }
21
+
22
+ export interface DeviceRecord {
23
+ id: string;
24
+ kind: DeviceKind;
25
+ source: DeviceSource;
26
+ name?: string;
27
+ state?: string;
28
+ ready?: boolean;
29
+ persistent?: boolean;
30
+ alwaysOn?: boolean;
31
+ region?: string;
32
+ template?: string;
33
+ previewUrl?: string;
34
+ timeoutRemainingMs?: number;
35
+ }
36
+
37
+ export interface DeviceListError {
38
+ source: DeviceSource;
39
+ message: string;
40
+ retryable: boolean;
41
+ }
42
+
43
+ export interface DeviceListParams {
44
+ kind?: "all" | "sandbox_worker" | "sandbox" | "computer";
45
+ }
46
+
47
+ export interface DeviceListResponse {
48
+ devices: DeviceRecord[];
49
+ errors: DeviceListError[];
50
+ }
51
+
52
+ const DEVICE_CATALOG: DeviceCatalogEntry[] = [
53
+ {
54
+ kind: "sandbox_worker",
55
+ label: "Sandbox Worker",
56
+ purpose:
57
+ "Isolated Linux workspace for agents to create files, run code, preview apps, snapshot, fork, and publish.",
58
+ lifecycle:
59
+ "Persistent by default; use stop/resume/snapshot/fork where the account backend supports saved state.",
60
+ persistence:
61
+ "Use one-hour timeouts for interactive builds and checkpoint before long pauses.",
62
+ primaryCommands: [
63
+ "miosa.sandboxes.create({ templateId: 'nextjs', timeoutSec: 3600 })",
64
+ "sandbox.exec.run('codex ...', { cwd: '/workspace' })",
65
+ "sandbox.files.write('/workspace/app/page.jsx', source)",
66
+ "miosa.deployments.publishFromSandbox(...)",
67
+ ],
68
+ useWhen: [
69
+ "Coding agents should build inside the remote filesystem.",
70
+ "You need command execution, file writes, package installs, previews, artifacts, or app publish.",
71
+ "You want virtual-device behavior without a GUI desktop.",
72
+ ],
73
+ avoidWhen: [
74
+ "The workflow requires full browser/desktop control.",
75
+ "The app is ready for production; publish it to a deployment runtime.",
76
+ ],
77
+ },
78
+ {
79
+ kind: "computer",
80
+ label: "Computer",
81
+ purpose:
82
+ "Durable VM/desktop device for browser automation, CUA sessions, SSH, tunnels, and persistent agent control.",
83
+ lifecycle:
84
+ "Managed as a Computer with desktop/browser and operator-style control surfaces.",
85
+ persistence:
86
+ "Use checkpoints, volumes, tunnels, and agent sessions for long-lived desktop workflows.",
87
+ primaryCommands: [
88
+ "miosa.computers.create({ name: 'browser-agent' })",
89
+ "computer.exec.run('npm test')",
90
+ "computer.desktop.open()",
91
+ ],
92
+ useWhen: [
93
+ "The agent needs Chromium or a full desktop.",
94
+ "The workflow logs into dashboards, fills forms, clicks buttons, or captures screenshots.",
95
+ "A human and agent share the same persistent machine state.",
96
+ ],
97
+ avoidWhen: [
98
+ "Simple code generation/build/test work fits a cheaper sandbox worker.",
99
+ "You only need durable app hosting.",
100
+ ],
101
+ },
102
+ {
103
+ kind: "local_device",
104
+ label: "Local Device",
105
+ purpose:
106
+ "Developer-owned machine connected through CLI/MCP for local discovery and private tooling.",
107
+ lifecycle: "Not hosted by MIOSA; the user owns uptime and state.",
108
+ persistence:
109
+ "State is local machine state. Do not assume cloud resume semantics.",
110
+ primaryCommands: ["miosa mcp install", "miosa doctor --json"],
111
+ useWhen: [
112
+ "The agent needs local repository discovery before cloud execution.",
113
+ "The user intentionally wants local private tools.",
114
+ ],
115
+ avoidWhen: [
116
+ "Customer code must stay isolated in MIOSA-hosted infrastructure.",
117
+ "The workflow needs reproducible shared cloud state.",
118
+ ],
119
+ },
120
+ {
121
+ kind: "docker_deploy_host",
122
+ label: "Docker Deploy Host",
123
+ purpose:
124
+ "Workspace appliance VM that runs Docker containers for durable apps published from sandboxes.",
125
+ lifecycle:
126
+ "Always-on deployment capacity; not an interactive coding workspace.",
127
+ persistence:
128
+ "Versioned releases and routing are durable; edits happen in sandboxes before publish.",
129
+ primaryCommands: [
130
+ "miosa sandbox publish <id> --docker-deploy",
131
+ "miosa deploy --docker-deploy",
132
+ ],
133
+ useWhen: [
134
+ "You need many small apps, APIs, funnels, or client sites in one workspace appliance.",
135
+ "You want stable public URLs backed by Docker containers.",
136
+ ],
137
+ avoidWhen: [
138
+ "Interactive agent work is still happening.",
139
+ "The app needs the standard MIOSA Deploy runtime.",
140
+ ],
141
+ },
142
+ ];
143
+
144
+ export class Devices {
145
+ private readonly http: HttpClient;
146
+
147
+ constructor(http: HttpClient) {
148
+ this.http = http;
149
+ }
150
+
151
+ /**
152
+ * Return the static device catalog used by orchestration apps to choose the
153
+ * right MIOSA execution surface before creating resources.
154
+ */
155
+ catalog(): DeviceCatalogEntry[] {
156
+ return DEVICE_CATALOG.map((entry) => ({ ...entry }));
157
+ }
158
+
159
+ /**
160
+ * List hosted devices by normalizing existing sandboxes and computers.
161
+ * Partial backend failures are returned in `errors` so orchestration UIs can
162
+ * still show usable inventory instead of failing the whole page.
163
+ */
164
+ async list(params: DeviceListParams = {}): Promise<DeviceListResponse> {
165
+ const kind = normalizeKind(params.kind ?? "all");
166
+ const devices: DeviceRecord[] = [];
167
+ const errors: DeviceListError[] = [];
168
+
169
+ if (kind === "all" || kind === "sandbox_worker") {
170
+ try {
171
+ const sandboxes = await this.http.get<unknown>("/sandboxes");
172
+ devices.push(...unwrapList(sandboxes, ["sandboxes"]).map(normalizeSandbox));
173
+ } catch (err) {
174
+ errors.push(toListError("sandboxes", err));
175
+ }
176
+ }
177
+
178
+ if (kind === "all" || kind === "computer") {
179
+ try {
180
+ const computers = await this.http.get<unknown>("/computers");
181
+ devices.push(...unwrapList(computers, ["computers"]).map(normalizeComputer));
182
+ } catch (err) {
183
+ errors.push(toListError("computers", err));
184
+ }
185
+ }
186
+
187
+ return { devices, errors };
188
+ }
189
+ }
190
+
191
+ function normalizeKind(
192
+ kind: NonNullable<DeviceListParams["kind"]>,
193
+ ): "all" | "sandbox_worker" | "computer" {
194
+ if (kind === "all") return "all";
195
+ if (kind === "sandbox" || kind === "sandbox_worker") {
196
+ return "sandbox_worker";
197
+ }
198
+ if (kind === "computer") return "computer";
199
+ throw new Error(`Unsupported device kind: ${kind}`);
200
+ }
201
+
202
+ function normalizeSandbox(row: Record<string, unknown>): DeviceRecord {
203
+ return compactRecord({
204
+ id: stringField(row, "id"),
205
+ kind: "sandbox_worker",
206
+ source: "sandboxes",
207
+ name: optionalString(row, "name"),
208
+ state: optionalString(row, "state") ?? optionalString(row, "status"),
209
+ ready: optionalBoolean(row, "ready"),
210
+ persistent: optionalBoolean(row, "persistent"),
211
+ alwaysOn: optionalBoolean(row, "always_on"),
212
+ template:
213
+ optionalString(row, "template_id") ?? optionalString(row, "template"),
214
+ previewUrl: optionalString(row, "preview_url"),
215
+ timeoutRemainingMs: optionalNumber(row, "timeout_remaining_ms"),
216
+ });
217
+ }
218
+
219
+ function normalizeComputer(row: Record<string, unknown>): DeviceRecord {
220
+ return compactRecord({
221
+ id: stringField(row, "id"),
222
+ kind: "computer",
223
+ source: "computers",
224
+ name: optionalString(row, "name"),
225
+ state: optionalString(row, "status") ?? optionalString(row, "state"),
226
+ ready: optionalBoolean(row, "ready"),
227
+ region: optionalString(row, "region"),
228
+ template:
229
+ optionalString(row, "template_type") ?? optionalString(row, "template"),
230
+ });
231
+ }
232
+
233
+ function compactRecord(record: Record<string, unknown>): DeviceRecord {
234
+ return Object.fromEntries(
235
+ Object.entries(record).filter(([, value]) => value !== undefined),
236
+ ) as unknown as DeviceRecord;
237
+ }
238
+
239
+ function unwrapList(payload: unknown, keys: string[]): Record<string, unknown>[] {
240
+ const value = isRecord(payload) && "data" in payload ? payload.data : payload;
241
+ if (Array.isArray(value)) return value.filter(isRecord);
242
+ if (isRecord(value)) {
243
+ for (const key of keys) {
244
+ const nested = value[key];
245
+ if (Array.isArray(nested)) return nested.filter(isRecord);
246
+ }
247
+ }
248
+ return [];
249
+ }
250
+
251
+ function toListError(source: DeviceSource, err: unknown): DeviceListError {
252
+ const message = err instanceof Error ? err.message : String(err);
253
+ return {
254
+ source,
255
+ message,
256
+ retryable: /fetch failed|ECONNRESET|HTTP 502|other side closed|socket hang up|bad gateway/i.test(
257
+ message,
258
+ ),
259
+ };
260
+ }
261
+
262
+ function stringField(row: Record<string, unknown>, key: string): string {
263
+ const value = row[key];
264
+ return typeof value === "string" ? value : String(value ?? "");
265
+ }
266
+
267
+ function optionalString(
268
+ row: Record<string, unknown>,
269
+ key: string,
270
+ ): string | undefined {
271
+ const value = row[key];
272
+ return typeof value === "string" && value.length > 0 ? value : undefined;
273
+ }
274
+
275
+ function optionalBoolean(
276
+ row: Record<string, unknown>,
277
+ key: string,
278
+ ): boolean | undefined {
279
+ return typeof row[key] === "boolean" ? row[key] : undefined;
280
+ }
281
+
282
+ function optionalNumber(
283
+ row: Record<string, unknown>,
284
+ key: string,
285
+ ): number | undefined {
286
+ return typeof row[key] === "number" ? row[key] : undefined;
287
+ }
288
+
289
+ function isRecord(value: unknown): value is Record<string, unknown> {
290
+ return value !== null && typeof value === "object" && !Array.isArray(value);
291
+ }
@@ -437,6 +437,7 @@ describe("Sandbox handle", () => {
437
437
  const deployment = await sandbox.deployDocker({
438
438
  name: "docker-site",
439
439
  port: 3000,
440
+ dockerDeployTemplateId: "nextjs-refero-design-pack",
440
441
  });
441
442
 
442
443
  expect(mockRequest).toHaveBeenCalledWith("/sandboxes/sbx_123/deploy", {
@@ -445,6 +446,7 @@ describe("Sandbox handle", () => {
445
446
  name: "docker-site",
446
447
  port: 3000,
447
448
  deployment_type: "docker_deploy",
449
+ docker_deploy_template_id: "nextjs-refero-design-pack",
448
450
  },
449
451
  });
450
452
  expect(deployment.deployment_product).toBe("docker_deploy");
@@ -329,6 +329,8 @@ export interface SandboxDeployParams {
329
329
  health_check_path?: string;
330
330
  deploymentType?: "miosa_deploy" | "docker_deploy" | "docker-deploy" | string;
331
331
  deployment_type?: "miosa_deploy" | "docker_deploy" | "docker-deploy" | string;
332
+ dockerDeployTemplateId?: string;
333
+ docker_deploy_template_id?: string;
332
334
  type?: "static" | "dynamic" | "server" | string;
333
335
  mode?: "static" | "dynamic" | "server" | string;
334
336
  database?: boolean | Record<string, unknown>;
@@ -1113,6 +1115,8 @@ export class Sandbox {
1113
1115
  port: params.port,
1114
1116
  health_check_path: params.healthCheckPath ?? params.health_check_path,
1115
1117
  deployment_type: params.deploymentType ?? params.deployment_type,
1118
+ docker_deploy_template_id:
1119
+ params.dockerDeployTemplateId ?? params.docker_deploy_template_id,
1116
1120
  type: params.type,
1117
1121
  mode: params.mode,
1118
1122
  database: params.database,
@@ -9,140 +9,58 @@ import type { HttpClient } from "../http.js";
9
9
  export interface TenantPlan {
10
10
  id?: string;
11
11
  name?: string;
12
+ preview_domain?: string | null;
13
+ deployment_domain?: string | null;
14
+ fallback_miosa_domain?: string | null;
12
15
  limits?: Record<string, unknown>;
13
16
  usage?: Record<string, unknown>;
14
17
  [key: string]: unknown;
15
18
  }
16
19
 
20
+ export interface BrandingData {
21
+ logo_url?: string | null;
22
+ primary_color?: string | null;
23
+ wordmark?: string | null;
24
+ favicon_url?: string | null;
25
+ [key: string]: unknown;
26
+ }
27
+
17
28
  export interface PreviewDomainData {
18
29
  preview_domain?: string | null;
19
- default_domain?: string;
30
+ deployment_domain?: string | null;
31
+ fallback_miosa_domain?: string | null;
20
32
  status?: string;
21
- dns_status?: string;
22
- cname_target?: string | null;
23
- dns_instructions?: unknown;
24
33
  [key: string]: unknown;
25
34
  }
26
35
 
27
36
  export interface TenantBrandingUpdateParams {
28
- product_name?: string;
29
- logo_url?: string;
30
- support_url?: string;
31
- support_email?: string;
32
- primary_color?: string;
33
- background_color?: string;
37
+ logo_url?: string | null;
38
+ primary_color?: string | null;
39
+ wordmark?: string | null;
40
+ favicon_url?: string | null;
34
41
  [key: string]: unknown;
35
42
  }
36
43
 
37
- export type BrandingData = TenantBrandingUpdateParams;
38
-
39
44
  // ── Helpers ───────────────────────────────────────────────────────────────────
40
45
 
41
46
  function unwrap<T>(payload: unknown): T {
42
47
  if (payload && typeof payload === "object") {
43
48
  const p = payload as Record<string, unknown>;
44
- for (const k of ["data", "tenant", "branding", "items"]) {
49
+ for (const k of ["data", "tenant", "items"]) {
45
50
  if (k in p) return p[k] as T;
46
51
  }
47
52
  }
48
53
  return payload as T;
49
54
  }
50
55
 
51
- // ── Sub-resources ────────────────────────────────────────────────────────────
52
-
53
- class PreviewDomain {
54
- constructor(private readonly http: HttpClient) {}
55
-
56
- /** Get the tenant's white-label preview domain settings. */
57
- async get(): Promise<PreviewDomainData> {
58
- const data = await this.http.get<unknown>("/tenant/preview-domain");
59
- return unwrap<PreviewDomainData>(data);
60
- }
61
-
62
- /** Set the tenant's white-label preview domain. */
63
- async set(domain: string): Promise<PreviewDomainData> {
64
- const data = await this.http.put<unknown>("/tenant/preview-domain", {
65
- preview_domain: domain,
66
- });
67
- return unwrap<PreviewDomainData>(data);
68
- }
69
-
70
- /** Re-run DNS verification for the configured preview domain. */
71
- async verify(): Promise<PreviewDomainData> {
72
- const data = await this.http.post<unknown>(
73
- "/tenant/preview-domain/verify",
74
- {},
75
- );
76
- return unwrap<PreviewDomainData>(data);
77
- }
78
-
79
- /** Remove the tenant's custom preview domain. */
80
- async delete(): Promise<void> {
81
- await this.http.delete<unknown>("/tenant/preview-domain");
82
- }
83
- }
84
-
85
- class Branding {
86
- constructor(private readonly http: HttpClient) {}
87
-
88
- /** Get tenant branding used by white-label hosted surfaces. */
89
- async get(): Promise<BrandingData> {
90
- const data = await this.http.get<unknown>("/tenant/branding");
91
- return unwrap<BrandingData>(data);
92
- }
93
-
94
- /** Update tenant branding used by white-label hosted surfaces. */
95
- async set(params: TenantBrandingUpdateParams): Promise<BrandingData> {
96
- const body = Object.fromEntries(
97
- Object.entries(params).filter(([, v]) => v !== undefined),
98
- );
99
- const data = await this.http.put<unknown>("/tenant/branding", {
100
- branding: body,
101
- });
102
- return unwrap<BrandingData>(data);
103
- }
104
-
105
- /** Reset tenant branding to platform defaults. */
106
- async delete(): Promise<void> {
107
- await this.http.delete<unknown>("/tenant/branding");
108
- }
109
- }
110
-
111
56
  // ── Main resource ─────────────────────────────────────────────────────────────
112
57
 
113
58
  export class Tenant {
114
- private readonly http: HttpClient;
115
- readonly preview_domain: PreviewDomain;
116
- readonly branding: Branding;
117
-
118
- /** camelCase alias for SDK consumers that avoid snake_case properties. */
119
- readonly previewDomain: PreviewDomain;
120
-
121
- constructor(http: HttpClient) {
122
- this.http = http;
123
- this.preview_domain = new PreviewDomain(http);
124
- this.previewDomain = this.preview_domain;
125
- this.branding = new Branding(http);
126
- }
59
+ constructor(private readonly http: HttpClient) {}
127
60
 
128
61
  /** Get the current tenant's plan, limits, and live usage counters. */
129
62
  async current(): Promise<TenantPlan> {
130
63
  const data = await this.http.get<unknown>("/tenant/plan");
131
64
  return unwrap<TenantPlan>(data);
132
65
  }
133
-
134
- /** Convenience alias for `tenant.branding.get()`. */
135
- async getBranding(): Promise<BrandingData> {
136
- return this.branding.get();
137
- }
138
-
139
- /** Convenience alias for `tenant.branding.set(...)`. */
140
- async setBranding(params: TenantBrandingUpdateParams): Promise<BrandingData> {
141
- return this.branding.set(params);
142
- }
143
-
144
- /** Convenience alias for `tenant.branding.delete()`. */
145
- async deleteBranding(): Promise<void> {
146
- await this.branding.delete();
147
- }
148
66
  }
@@ -66,6 +66,11 @@ export interface WebhookUpdateParams {
66
66
  [key: string]: unknown;
67
67
  }
68
68
 
69
+ export interface WebhookSignatureVerifyOptions {
70
+ /** Maximum age for the webhook timestamp in seconds. Defaults to 300. */
71
+ toleranceSeconds?: number;
72
+ }
73
+
69
74
  // ── Helpers ───────────────────────────────────────────────────────────────────
70
75
 
71
76
  function unwrap<T>(payload: unknown): T {
@@ -101,63 +106,73 @@ function idempotencyKey(key?: string): string {
101
106
  return key ?? randomUUID();
102
107
  }
103
108
 
104
- function bodyBuffer(body: Buffer | Uint8Array | string): Buffer {
105
- if (Buffer.isBuffer(body)) return body;
106
- if (typeof body === "string") return Buffer.from(body, "utf8");
107
- return Buffer.from(body);
109
+ function parseSignatureHeader(header: string): {
110
+ timestamp: number;
111
+ signatures: string[];
112
+ } | null {
113
+ const parts = header.split(",").map((part) => part.trim());
114
+ let timestamp: number | null = null;
115
+ const signatures: string[] = [];
116
+
117
+ for (const part of parts) {
118
+ const [key, value] = part.split("=", 2);
119
+ if (!key || !value) continue;
120
+ if (key === "t") {
121
+ const parsed = Number(value);
122
+ if (Number.isFinite(parsed)) timestamp = parsed;
123
+ } else if (key === "v1") {
124
+ signatures.push(value);
125
+ }
126
+ }
127
+
128
+ if (timestamp == null || signatures.length === 0) return null;
129
+ return { timestamp, signatures };
108
130
  }
109
131
 
110
132
  /**
111
- * Verify the `Miosa-Signature` webhook header.
133
+ * Verify a MIOSA webhook signature header.
112
134
  *
113
- * Header format: `t=<unix_seconds>,v1=<hex_hmac>`.
114
- * Signed payload: `<timestamp>.<raw_body>`.
135
+ * Header format: `t=<unix_seconds>,v1=<hex_hmac_sha256>`.
115
136
  */
116
137
  export function verifySignature(
117
- body: Buffer | Uint8Array | string,
138
+ body: string | Buffer | Uint8Array,
118
139
  header: string,
119
140
  secret: string,
120
- toleranceSec = 300,
141
+ options: WebhookSignatureVerifyOptions = {},
121
142
  ): boolean {
122
- const parts = Object.fromEntries(
123
- header
124
- .split(",")
125
- .map((chunk) => chunk.split("=", 2))
126
- .filter(([key, value]) => key && value),
127
- );
128
-
129
- const timestamp = parts.t;
130
- const received = parts.v1;
131
- if (!timestamp || !received || !secret) return false;
132
-
133
- const unixSeconds = Number(timestamp);
134
- if (!Number.isFinite(unixSeconds)) return false;
143
+ const parsed = parseSignatureHeader(header);
144
+ if (!parsed) return false;
135
145
 
136
- const ageSec = Math.abs(Date.now() / 1000 - unixSeconds);
137
- if (ageSec > toleranceSec) {
138
- throw new Error("webhook timestamp too old");
146
+ const toleranceSeconds = options.toleranceSeconds ?? 300;
147
+ const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - parsed.timestamp);
148
+ if (ageSeconds > toleranceSeconds) {
149
+ throw new Error("Webhook signature timestamp is too old");
139
150
  }
140
151
 
141
- const rawBody = bodyBuffer(body);
142
- const signed = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
143
- const expected = createHmac("sha256", secret).update(signed).digest("hex");
144
-
145
- try {
146
- return timingSafeEqual(
147
- Buffer.from(expected, "utf8"),
148
- Buffer.from(received, "utf8"),
149
- );
150
- } catch {
151
- return false;
152
- }
152
+ const bodyBuffer = Buffer.isBuffer(body) ? body : Buffer.from(body);
153
+ const signedPayload = Buffer.concat([
154
+ Buffer.from(`${parsed.timestamp}.`),
155
+ bodyBuffer,
156
+ ]);
157
+ const expected = createHmac("sha256", secret)
158
+ .update(signedPayload)
159
+ .digest("hex");
160
+ const expectedBuffer = Buffer.from(expected, "hex");
161
+
162
+ return parsed.signatures.some((signature) => {
163
+ const actualBuffer = Buffer.from(signature, "hex");
164
+ if (actualBuffer.length !== expectedBuffer.length) return false;
165
+ return timingSafeEqual(actualBuffer, expectedBuffer);
166
+ });
153
167
  }
154
168
 
155
169
  // ── Main resource ─────────────────────────────────────────────────────────────
156
170
 
157
171
  export class Webhooks {
158
- constructor(private readonly http: HttpClient) {}
159
-
160
172
  static verifySignature = verifySignature;
173
+ static verify_signature = verifySignature;
174
+
175
+ constructor(private readonly http: HttpClient) {}
161
176
 
162
177
  async list(params: WebhookListParams = {}): Promise<WebhookData[]> {
163
178
  const query = stripUndefined({ ...params }) as Record<
package/src/types.ts CHANGED
@@ -78,9 +78,9 @@ export interface ComputerData {
78
78
  id: ComputerId;
79
79
  name: string;
80
80
  /**
81
- * URL-safe identifier used in preview URLs: `https://{port}-{slug}.sandbox.{preview_domain}`.
82
- * Falls back to the computer id when no slug is assigned. The domain is the
83
- * tenant's white-label `preview_domain` (server-provided) never hardcode it.
81
+ * URL-safe identifier used in preview URLs:
82
+ * `https://{port}-{slug}.sandbox.{preview_domain}`.
83
+ * Falls back to the computer id when no slug is assigned.
84
84
  */
85
85
  slug: string;
86
86
  status: ComputerStatus;
@@ -91,9 +91,9 @@ export interface ComputerData {
91
91
  metadata: Record<string, string>;
92
92
  /** Controls who can access the HTTP preview URL. Defaults to `"public"`. */
93
93
  visibility: ComputerVisibility;
94
- /** Public ingress root, e.g. `https://<slug>.sandbox.<preview_domain>` (server-provided). */
94
+ /** Public ingress root, e.g. `https://<slug>.sandbox.<preview_domain>`. */
95
95
  sandbox_url?: string;
96
- /** Tenant's white-label preview/base domain (e.g. `cliniciq.com`). Use to build preview URLs. */
96
+ /** Tenant's white-label preview/base domain, e.g. `cliniciq.com`. */
97
97
  preview_domain?: string;
98
98
  /** KasmVNC URL for desktop templates. */
99
99
  desktop_url?: string;