@miosa/sdk 1.2.0 → 1.2.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miosa/sdk",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "TypeScript SDK for the MIOSA API — cloud VM desktop infrastructure for AI agents",
5
5
  "license": "MIT",
6
6
  "author": "MIOSA <hello@miosa.ai>",
package/src/index.ts CHANGED
@@ -122,6 +122,7 @@ export type {
122
122
  DeploymentData,
123
123
  DeploymentId,
124
124
  DeploymentListParams,
125
+ DeploymentProduct,
125
126
  DeploymentReleaseData,
126
127
  DeploymentReleaseId,
127
128
  DeploymentServiceData,
@@ -134,6 +135,7 @@ export type {
134
135
  DeploymentVersionId,
135
136
  DeploymentVersionKind,
136
137
  DeploymentVersionState,
138
+ DockerDeployCreateParams,
137
139
  ExternalAttribution,
138
140
  PublishFromSandboxParams,
139
141
  PublishParams,
@@ -216,7 +218,7 @@ export type {
216
218
  HealthCheckCreateParams,
217
219
  HealthCheckUpdateParams,
218
220
  } from "./resources/health-checks.js";
219
- export { Webhooks } from "./resources/webhooks.js";
221
+ export { verifySignature, Webhooks } from "./resources/webhooks.js";
220
222
  export type {
221
223
  WebhookId,
222
224
  WebhookDeliveryId,
@@ -285,7 +287,12 @@ export type {
285
287
 
286
288
  // P2 resources
287
289
  export { Tenant } from "./resources/tenant.js";
288
- export type { TenantPlan } from "./resources/tenant.js";
290
+ export type {
291
+ BrandingData,
292
+ PreviewDomainData,
293
+ TenantBrandingUpdateParams,
294
+ TenantPlan,
295
+ } from "./resources/tenant.js";
289
296
  export { Regions } from "./resources/regions.js";
290
297
  export type {
291
298
  RegionData,
@@ -176,21 +176,26 @@ export class Computer {
176
176
  * ```ts
177
177
  * await computer.exec.bash("npm run dev &");
178
178
  * const url = computer.previewUrl(3000);
179
- * // => https://3000-<slug>.sandbox.miosa.ai
179
+ * // => https://3000-<slug>.sandbox.<tenant-domain>
180
180
  * ```
181
181
  *
182
182
  * Works for any TCP HTTP listener. Public (no auth required); anyone with
183
183
  * the URL can see it. Served over the ingress proxy so it inherits the
184
- * wildcard TLS cert — no per-sandbox certs to manage.
184
+ * tenant's white-label preview domain — no per-sandbox certs to manage.
185
185
  */
186
186
  previewUrl(port: number, path: string = "/"): string {
187
187
  const p = path.startsWith("/") ? path : `/${path}`;
188
- return `https://${port}-${this.slug}.sandbox.miosa.ai${p}`;
188
+ return `https://${port}-${this.slug}.sandbox.${this.previewDomain}${p}`;
189
189
  }
190
190
 
191
191
  /** Root preview URL — serves whatever is on the default app port. */
192
192
  get publicUrl(): string {
193
- return `https://${this.slug}.sandbox.miosa.ai`;
193
+ return `https://${this.slug}.sandbox.${this.previewDomain}`;
194
+ }
195
+
196
+ /** Tenant's preview/base domain, white-label aware. */
197
+ get previewDomain(): string {
198
+ return this.data.preview_domain || "miosa.app";
194
199
  }
195
200
 
196
201
  // ─── Lifecycle ─────────────────────────────────────────────────────────────
@@ -0,0 +1,55 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { HttpClient } from "../http.js";
3
+ import { Deployments } from "./deployments.js";
4
+
5
+ const mockRequest = vi.fn();
6
+
7
+ function makeHttp(): HttpClient {
8
+ const http = {} as HttpClient;
9
+ http.request = mockRequest;
10
+ return http;
11
+ }
12
+
13
+ beforeEach(() => {
14
+ vi.clearAllMocks();
15
+ });
16
+
17
+ describe("Deployments", () => {
18
+ it("createDockerDeploy() marks the deployment for Docker Deploy", async () => {
19
+ mockRequest.mockResolvedValue({
20
+ data: {
21
+ id: "dep_123",
22
+ tenant_id: "ten_123",
23
+ name: "Clinic Intake",
24
+ slug: "clinic-intake",
25
+ state: "pending",
26
+ deployment_product: "docker_deploy",
27
+ docker_deploy_host_id: "ddh_123",
28
+ metadata: { deployment_product: "docker_deploy", client: "clinic-iq" },
29
+ },
30
+ });
31
+
32
+ const deployment = await new Deployments(makeHttp()).createDockerDeploy({
33
+ name: "Clinic Intake",
34
+ repoUrl: "https://github.com/clinic-iq/intake",
35
+ externalWorkspaceId: "dr-smith",
36
+ externalProjectId: "lead-magnet",
37
+ metadata: { client: "clinic-iq" },
38
+ idempotencyKey: "idem-123",
39
+ });
40
+
41
+ expect(mockRequest).toHaveBeenCalledWith("/deployments", {
42
+ method: "POST",
43
+ body: {
44
+ name: "Clinic Intake",
45
+ repo_url: "https://github.com/clinic-iq/intake",
46
+ metadata: { client: "clinic-iq", deployment_product: "docker_deploy" },
47
+ external_workspace_id: "dr-smith",
48
+ external_project_id: "lead-magnet",
49
+ },
50
+ headers: { "Idempotency-Key": "idem-123" },
51
+ });
52
+ expect(deployment.deployment_product).toBe("docker_deploy");
53
+ expect(deployment.docker_deploy_host_id).toBe("ddh_123");
54
+ });
55
+ });
@@ -50,6 +50,8 @@ export type DeploymentVersionState =
50
50
 
51
51
  export type DeploymentSourceType = "repo" | "sandbox" | "upload";
52
52
 
53
+ export type DeploymentProduct = "miosa_deploy" | "docker_deploy";
54
+
53
55
  export type DeploymentServiceType =
54
56
  | "static_web"
55
57
  | "web"
@@ -107,6 +109,8 @@ export interface DeploymentData {
107
109
  auto_deploy?: boolean;
108
110
  custom_domain_id?: string | null;
109
111
  linked_database_id?: string | null;
112
+ deployment_product?: DeploymentProduct | string | null;
113
+ docker_deploy_host_id?: string | null;
110
114
  metadata?: Record<string, unknown>;
111
115
  external_workspace_id?: string | null;
112
116
  external_user_id?: string | null;
@@ -119,7 +123,7 @@ export interface DeploymentData {
119
123
  export type DeploymentDatabaseRequest =
120
124
  | boolean
121
125
  | {
122
- engine?: "postgresql" | "mysql" | "redis";
126
+ engine?: "postgresql" | "mysql" | "redis" | "qdrant";
123
127
  size?: "xs" | "small" | "medium" | "large";
124
128
  storage_mb?: number;
125
129
  region?: string;
@@ -281,6 +285,8 @@ export interface DeploymentCreateParams extends ExternalAttribution {
281
285
  idempotencyKey?: string;
282
286
  }
283
287
 
288
+ export interface DockerDeployCreateParams extends DeploymentCreateParams {}
289
+
284
290
  export interface DeploymentUpdateParams {
285
291
  name?: string;
286
292
  branch?: string;
@@ -421,6 +427,15 @@ function stripUndefined(
421
427
  );
422
428
  }
423
429
 
430
+ function dockerDeployMetadata(
431
+ metadata: Record<string, unknown> | undefined,
432
+ ): Record<string, unknown> {
433
+ return {
434
+ ...(metadata ?? {}),
435
+ deployment_product: "docker_deploy",
436
+ };
437
+ }
438
+
424
439
  // ── Sub-resources ──────────────────────────────────────────────────────────
425
440
 
426
441
  export class DeploymentVersions {
@@ -627,6 +642,20 @@ export class Deployments {
627
642
  return unwrap(data) as DeploymentData;
628
643
  }
629
644
 
645
+ /**
646
+ * Create a deployment that runs on the workspace's dedicated Docker Deploy
647
+ * runtime. It uses the same /deployments API as MIOSA Deploy, but marks the
648
+ * deployment so the control plane attaches it to the workspace Docker host.
649
+ */
650
+ async createDockerDeploy(
651
+ params: DockerDeployCreateParams,
652
+ ): Promise<DeploymentData> {
653
+ return this.create({
654
+ ...params,
655
+ metadata: dockerDeployMetadata(params.metadata),
656
+ });
657
+ }
658
+
630
659
  async update(
631
660
  deploymentId: string,
632
661
  params: DeploymentUpdateParams,
@@ -393,10 +393,10 @@ describe("Sandbox handle", () => {
393
393
  it("pause/resume/deploy use native sandbox lifecycle endpoints", async () => {
394
394
  mockPost
395
395
  .mockResolvedValueOnce({ data: sandboxData({ state: "paused" }) })
396
- .mockResolvedValueOnce({ data: sandboxData({ state: "running" }) })
397
- .mockResolvedValueOnce({
396
+ .mockResolvedValueOnce({ data: sandboxData({ state: "running" }) });
397
+ mockRequest.mockResolvedValueOnce({
398
398
  data: { deployment_id: "dep_1", url: "https://app.miosa.app" },
399
- });
399
+ });
400
400
 
401
401
  const sandbox = new Sandbox(makeHttp(), sandboxData());
402
402
  await sandbox.pause();
@@ -413,14 +413,43 @@ describe("Sandbox handle", () => {
413
413
  "/sandboxes/sbx_123/resume",
414
414
  {},
415
415
  );
416
- expect(mockPost).toHaveBeenNthCalledWith(3, "/sandboxes/sbx_123/deploy", {
417
- name: "site",
418
- path: "/workspace/dist",
419
- custom_domain: "example.com",
416
+ expect(mockRequest).toHaveBeenCalledWith("/sandboxes/sbx_123/deploy", {
417
+ method: "POST",
418
+ body: {
419
+ name: "site",
420
+ output_path: "/workspace/dist",
421
+ custom_domain: "example.com",
422
+ },
420
423
  });
421
424
  expect(deployment.deployment_id).toBe("dep_1");
422
425
  });
423
426
 
427
+ it("deployDocker marks sandbox deployment for Docker Deploy", async () => {
428
+ mockRequest.mockResolvedValueOnce({
429
+ data: {
430
+ deployment_id: "dep_2",
431
+ deployment_product: "docker_deploy",
432
+ data: { deployment: { docker_deploy_host_id: "ddh_123" } },
433
+ },
434
+ });
435
+
436
+ const sandbox = new Sandbox(makeHttp(), sandboxData());
437
+ const deployment = await sandbox.deployDocker({
438
+ name: "docker-site",
439
+ port: 3000,
440
+ });
441
+
442
+ expect(mockRequest).toHaveBeenCalledWith("/sandboxes/sbx_123/deploy", {
443
+ method: "POST",
444
+ body: {
445
+ name: "docker-site",
446
+ port: 3000,
447
+ deployment_type: "docker_deploy",
448
+ },
449
+ });
450
+ expect(deployment.deployment_product).toBe("docker_deploy");
451
+ });
452
+
424
453
  it("throws before operations when not running", async () => {
425
454
  const sandbox = new Sandbox(
426
455
  makeHttp(),
@@ -318,6 +318,21 @@ export interface SandboxDeployParams {
318
318
  sourceSnapshotPath?: string;
319
319
  source_snapshot_path?: string;
320
320
  entrypoint?: string;
321
+ buildCommand?: string;
322
+ build_command?: string;
323
+ runCommand?: string;
324
+ run_command?: string;
325
+ startCommand?: string;
326
+ start_command?: string;
327
+ port?: number;
328
+ healthCheckPath?: string;
329
+ health_check_path?: string;
330
+ deploymentType?: "miosa_deploy" | "docker_deploy" | "docker-deploy" | string;
331
+ deployment_type?: "miosa_deploy" | "docker_deploy" | "docker-deploy" | string;
332
+ type?: "static" | "dynamic" | "server" | string;
333
+ mode?: "static" | "dynamic" | "server" | string;
334
+ database?: boolean | Record<string, unknown>;
335
+ resources?: Record<string, unknown>;
321
336
  domain?: string;
322
337
  customDomain?: string;
323
338
  custom_domain?: string;
@@ -1092,6 +1107,16 @@ export class Sandbox {
1092
1107
  source_snapshot_path:
1093
1108
  params.sourceSnapshotPath ?? params.source_snapshot_path,
1094
1109
  entrypoint: params.entrypoint,
1110
+ build_command: params.buildCommand ?? params.build_command,
1111
+ run_command: params.runCommand ?? params.run_command,
1112
+ start_command: params.startCommand ?? params.start_command,
1113
+ port: params.port,
1114
+ health_check_path: params.healthCheckPath ?? params.health_check_path,
1115
+ deployment_type: params.deploymentType ?? params.deployment_type,
1116
+ type: params.type,
1117
+ mode: params.mode,
1118
+ database: params.database,
1119
+ resources: params.resources,
1095
1120
  domain: params.domain,
1096
1121
  custom_domain: params.customDomain ?? params.custom_domain,
1097
1122
  }),
@@ -1108,6 +1133,12 @@ export class Sandbox {
1108
1133
  );
1109
1134
  }
1110
1135
 
1136
+ async deployDocker(
1137
+ params: SandboxDeployParams = {},
1138
+ ): Promise<Record<string, unknown>> {
1139
+ return this.deploy({ ...params, deploymentType: "docker_deploy" });
1140
+ }
1141
+
1111
1142
  /** Check readiness of the sandbox (GET /sandboxes/:id/readiness). */
1112
1143
  async readiness(): Promise<Record<string, unknown>> {
1113
1144
  return unwrap(
@@ -9,11 +9,38 @@ 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
+
28
+ export interface PreviewDomainData {
29
+ preview_domain?: string | null;
30
+ deployment_domain?: string | null;
31
+ fallback_miosa_domain?: string | null;
32
+ status?: string;
33
+ [key: string]: unknown;
34
+ }
35
+
36
+ export interface TenantBrandingUpdateParams {
37
+ logo_url?: string | null;
38
+ primary_color?: string | null;
39
+ wordmark?: string | null;
40
+ favicon_url?: string | null;
41
+ [key: string]: unknown;
42
+ }
43
+
17
44
  // ── Helpers ───────────────────────────────────────────────────────────────────
18
45
 
19
46
  function unwrap<T>(payload: unknown): T {
@@ -2,7 +2,7 @@
2
2
  * Webhooks resource — tenant outgoing event delivery.
3
3
  */
4
4
 
5
- import { randomUUID } from "node:crypto";
5
+ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
6
6
 
7
7
  import type { HttpClient } from "../http.js";
8
8
 
@@ -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,9 +106,72 @@ function idempotencyKey(key?: string): string {
101
106
  return key ?? randomUUID();
102
107
  }
103
108
 
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 };
130
+ }
131
+
132
+ /**
133
+ * Verify a MIOSA webhook signature header.
134
+ *
135
+ * Header format: `t=<unix_seconds>,v1=<hex_hmac_sha256>`.
136
+ */
137
+ export function verifySignature(
138
+ body: string | Buffer | Uint8Array,
139
+ header: string,
140
+ secret: string,
141
+ options: WebhookSignatureVerifyOptions = {},
142
+ ): boolean {
143
+ const parsed = parseSignatureHeader(header);
144
+ if (!parsed) return false;
145
+
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");
150
+ }
151
+
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
+ });
167
+ }
168
+
104
169
  // ── Main resource ─────────────────────────────────────────────────────────────
105
170
 
106
171
  export class Webhooks {
172
+ static verifySignature = verifySignature;
173
+ static verify_signature = verifySignature;
174
+
107
175
  constructor(private readonly http: HttpClient) {}
108
176
 
109
177
  async list(params: WebhookListParams = {}): Promise<WebhookData[]> {
package/src/types.ts CHANGED
@@ -78,8 +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.miosa.ai`.
82
- * Falls back to the computer id when no slug is assigned.
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.
83
84
  */
84
85
  slug: string;
85
86
  status: ComputerStatus;
@@ -90,8 +91,10 @@ export interface ComputerData {
90
91
  metadata: Record<string, string>;
91
92
  /** Controls who can access the HTTP preview URL. Defaults to `"public"`. */
92
93
  visibility: ComputerVisibility;
93
- /** Public ingress root, e.g. `https://<slug>.sandbox.miosa.ai`. */
94
+ /** Public ingress root, e.g. `https://<slug>.sandbox.<preview_domain>` (server-provided). */
94
95
  sandbox_url?: string;
96
+ /** Tenant's white-label preview/base domain (e.g. `cliniciq.com`). Use to build preview URLs. */
97
+ preview_domain?: string;
95
98
  /** KasmVNC URL for desktop templates. */
96
99
  desktop_url?: string;
97
100
  created_at: string;
@@ -1,187 +0,0 @@
1
- import * as crypto from "node:crypto";
2
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
- import type { HttpClient } from "../http.js";
4
- import { Sandbox } from "./sandboxes.js";
5
- import { Tenant } from "./tenant.js";
6
- import { verifySignature, Webhooks } from "./webhooks.js";
7
-
8
- const mockGet = vi.fn();
9
- const mockPost = vi.fn();
10
- const mockPut = vi.fn();
11
- const mockPatch = vi.fn();
12
- const mockDelete = vi.fn();
13
- const mockRequest = vi.fn();
14
-
15
- function makeHttp(): HttpClient {
16
- return {
17
- get: mockGet,
18
- post: mockPost,
19
- put: mockPut,
20
- patch: mockPatch,
21
- delete: mockDelete,
22
- request: mockRequest,
23
- } as unknown as HttpClient;
24
- }
25
-
26
- function sandboxData(overrides: Record<string, unknown> = {}) {
27
- return {
28
- id: "sbx_123",
29
- state: "running",
30
- ready: true,
31
- template_id: "miosa-sandbox",
32
- ...overrides,
33
- };
34
- }
35
-
36
- beforeEach(() => {
37
- vi.resetAllMocks();
38
- });
39
-
40
- // ── tenant.preview_domain ────────────────────────────────────────────────────
41
-
42
- describe("tenant.preview_domain", () => {
43
- it("get calls GET /tenant/preview-domain", async () => {
44
- mockGet.mockResolvedValue({
45
- domain: "preview.acme.com",
46
- verified_at: null,
47
- });
48
- const tenant = new Tenant(makeHttp());
49
- const result = await tenant.preview_domain.get();
50
- expect(mockGet).toHaveBeenCalledWith("/tenant/preview-domain");
51
- expect(result.domain).toBe("preview.acme.com");
52
- });
53
-
54
- it("set calls PUT /tenant/preview-domain", async () => {
55
- mockPut.mockResolvedValue({ domain: "preview.acme.com" });
56
- const tenant = new Tenant(makeHttp());
57
- await tenant.preview_domain.set("preview.acme.com");
58
- expect(mockPut).toHaveBeenCalledWith("/tenant/preview-domain", {
59
- domain: "preview.acme.com",
60
- });
61
- });
62
-
63
- it("verify calls POST /tenant/preview-domain/verify", async () => {
64
- mockPost.mockResolvedValue({
65
- verified: true,
66
- target: "proxy.miosa.app",
67
- records: [],
68
- });
69
- const tenant = new Tenant(makeHttp());
70
- const result = await tenant.preview_domain.verify();
71
- expect(mockPost).toHaveBeenCalledWith("/tenant/preview-domain/verify", {});
72
- expect(result.verified).toBe(true);
73
- });
74
-
75
- it("delete calls DELETE /tenant/preview-domain", async () => {
76
- mockDelete.mockResolvedValue(undefined);
77
- const tenant = new Tenant(makeHttp());
78
- await tenant.preview_domain.delete();
79
- expect(mockDelete).toHaveBeenCalledWith("/tenant/preview-domain");
80
- });
81
- });
82
-
83
- // ── tenant.branding ──────────────────────────────────────────────────────────
84
-
85
- describe("tenant.branding", () => {
86
- it("get calls GET /tenant/branding", async () => {
87
- mockGet.mockResolvedValue({ product_name: "Acme AI" });
88
- const tenant = new Tenant(makeHttp());
89
- const result = await tenant.branding.get();
90
- expect(result.product_name).toBe("Acme AI");
91
- });
92
-
93
- it("set calls PUT /tenant/branding", async () => {
94
- const branding = { product_name: "Acme", primary_color: "#ff0000" };
95
- mockPut.mockResolvedValue(branding);
96
- const tenant = new Tenant(makeHttp());
97
- await tenant.branding.set(branding);
98
- expect(mockPut).toHaveBeenCalledWith("/tenant/branding", branding);
99
- });
100
- });
101
-
102
- // ── sandbox.update ───────────────────────────────────────────────────────────
103
-
104
- describe("sandbox.update", () => {
105
- it("calls PATCH /sandboxes/{id} with body", async () => {
106
- const updated = sandboxData({ name: "renamed" });
107
- mockPatch.mockResolvedValue({ data: updated });
108
- const sbx = new Sandbox(makeHttp(), sandboxData());
109
- await sbx.update({ name: "renamed", slug: "my-slug" });
110
- expect(mockPatch).toHaveBeenCalledWith("/sandboxes/sbx_123", {
111
- name: "renamed",
112
- slug: "my-slug",
113
- });
114
- });
115
-
116
- it("only sends defined fields", async () => {
117
- mockPatch.mockResolvedValue({ data: sandboxData() });
118
- const sbx = new Sandbox(makeHttp(), sandboxData());
119
- await sbx.update({ always_on: true });
120
- expect(mockPatch).toHaveBeenCalledWith("/sandboxes/sbx_123", {
121
- always_on: true,
122
- });
123
- });
124
- });
125
-
126
- // ── sandbox.previewToken ─────────────────────────────────────────────────────
127
-
128
- describe("sandbox.previewToken", () => {
129
- it("calls POST /sandboxes/{id}/preview-token", async () => {
130
- const tokenResp = {
131
- token: "tok_xyz",
132
- url: "https://preview.miosa.app?t=tok_xyz",
133
- expires_at: "2026-05-26T01:00:00Z",
134
- scope: "read",
135
- };
136
- mockPost.mockResolvedValue(tokenResp);
137
- const sbx = new Sandbox(makeHttp(), sandboxData());
138
- const result = await sbx.previewToken(3600, "read");
139
- expect(mockPost).toHaveBeenCalledWith("/sandboxes/sbx_123/preview-token", {
140
- expires_in: 3600,
141
- scope: "read",
142
- });
143
- expect(result.token).toBe("tok_xyz");
144
- });
145
- });
146
-
147
- // ── verifySignature ──────────────────────────────────────────────────────────
148
-
149
- function makeHeader(payload: Buffer, secret: string, ts?: number): string {
150
- const t = ts ?? Math.floor(Date.now() / 1000);
151
- const signed = Buffer.concat([Buffer.from(`${t}.`), payload]);
152
- const sig = crypto.createHmac("sha256", secret).update(signed).digest("hex");
153
- return `t=${t},v1=${sig}`;
154
- }
155
-
156
- describe("verifySignature", () => {
157
- it("returns true for a valid signature", () => {
158
- const payload = Buffer.from('{"event":"sandbox.created"}');
159
- const header = makeHeader(payload, "secret123");
160
- expect(verifySignature(payload, header, "secret123")).toBe(true);
161
- });
162
-
163
- it("returns false for wrong secret", () => {
164
- const payload = Buffer.from('{"event":"sandbox.created"}');
165
- const header = makeHeader(payload, "secret123");
166
- expect(verifySignature(payload, header, "wrongsecret")).toBe(false);
167
- });
168
-
169
- it("throws for old timestamp", () => {
170
- const payload = Buffer.from("body");
171
- const old = Math.floor(Date.now() / 1000) - 400;
172
- const header = makeHeader(payload, "s3cr3t", old);
173
- expect(() => verifySignature(payload, header, "s3cr3t")).toThrow("too old");
174
- });
175
-
176
- it("returns false for malformed header", () => {
177
- expect(verifySignature(Buffer.from("body"), "malformed", "secret")).toBe(
178
- false,
179
- );
180
- });
181
-
182
- it("Webhooks.verifySignature delegates to module function", () => {
183
- const payload = Buffer.from("body");
184
- const header = makeHeader(payload, "secret");
185
- expect(Webhooks.verifySignature(payload, header, "secret")).toBe(true);
186
- });
187
- });