@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/dist/index.d.ts +75 -7
- package/dist/index.js +80 -6
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +9 -2
- package/src/resources/computer.ts +9 -4
- package/src/resources/deployments.test.ts +55 -0
- package/src/resources/deployments.ts +30 -1
- package/src/resources/sandboxes.test.ts +36 -7
- package/src/resources/sandboxes.ts +31 -0
- package/src/resources/tenant.ts +27 -0
- package/src/resources/webhooks.ts +69 -1
- package/src/types.ts +6 -3
- package/src/resources/phase1.test.ts +0 -187
- package/src/resources/quotas.ts +0 -77
- package/src/resources/sandbox-processes.ts +0 -112
- package/src/resources/sandbox-shares.ts +0 -83
- package/src/resources/tenant-events.ts +0 -32
- package/src/resources/workspaces.ts +0 -285
package/package.json
CHANGED
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 {
|
|
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
|
|
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
|
-
*
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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(
|
package/src/resources/tenant.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
});
|