@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.
- package/README.md +63 -39
- package/dist/index.d.ts +201 -182
- package/dist/index.js +433 -309
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +5 -5
- package/src/index.ts +16 -11
- package/src/resources/admin.ts +0 -11
- package/src/resources/api-keys.ts +0 -16
- package/src/resources/custom_domains.ts +1 -1
- package/src/resources/deployments.test.ts +127 -51
- package/src/resources/deployments.ts +326 -125
- package/src/resources/devices.test.ts +92 -0
- package/src/resources/devices.ts +291 -0
- package/src/resources/sandboxes.test.ts +2 -0
- package/src/resources/sandboxes.ts +4 -0
- package/src/resources/tenant.ts +19 -101
- package/src/resources/webhooks.ts +54 -39
- package/src/types.ts +5 -5
- package/src/resources/docker-deploy.test.ts +0 -102
- package/src/resources/docker-deploy.ts +0 -183
- package/src/resources/governance.test.ts +0 -355
- package/src/resources/governance.ts +0 -528
- 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
|
@@ -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,
|
package/src/resources/tenant.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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", "
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
133
|
+
* Verify a MIOSA webhook signature header.
|
|
112
134
|
*
|
|
113
|
-
* Header format: `t=<unix_seconds>,v1=<
|
|
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:
|
|
138
|
+
body: string | Buffer | Uint8Array,
|
|
118
139
|
header: string,
|
|
119
140
|
secret: string,
|
|
120
|
-
|
|
141
|
+
options: WebhookSignatureVerifyOptions = {},
|
|
121
142
|
): boolean {
|
|
122
|
-
const
|
|
123
|
-
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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:
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
|
94
|
+
/** Public ingress root, e.g. `https://<slug>.sandbox.<preview_domain>`. */
|
|
95
95
|
sandbox_url?: string;
|
|
96
|
-
/** Tenant's white-label preview/base domain
|
|
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;
|