@miosa/sdk 1.0.0 → 1.1.0

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.
@@ -1,5 +1,22 @@
1
1
  import { MiosaError } from "../errors.js";
2
2
  import { HttpClient } from "../http.js";
3
+ import { SandboxAudit } from "./egressAudit.js";
4
+ import { SandboxNetwork } from "./egressNetwork.js";
5
+ import { SandboxSecrets } from "./egressSecrets.js";
6
+
7
+ function encodeContent(content: string | Uint8Array): string {
8
+ const bytes =
9
+ typeof content === "string" ? new TextEncoder().encode(content) : content;
10
+ const maybeBuffer = (
11
+ globalThis as {
12
+ Buffer?: { from(b: Uint8Array): { toString(e: string): string } };
13
+ }
14
+ ).Buffer;
15
+ if (maybeBuffer) return maybeBuffer.from(bytes).toString("base64");
16
+ let bin = "";
17
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
18
+ return btoa(bin);
19
+ }
3
20
 
4
21
  export const SANDBOX_TEMPLATE = "miosa-sandbox";
5
22
 
@@ -47,6 +64,7 @@ export interface SandboxCreateParams {
47
64
  tags?: string[];
48
65
  idempotencyKey?: string;
49
66
  idempotency_key?: string;
67
+ slug?: string;
50
68
  // White-label attribution. See platform/attribution docs.
51
69
  externalWorkspaceId?: string;
52
70
  external_workspace_id?: string;
@@ -231,6 +249,37 @@ export interface SandboxFileEntry {
231
249
  [key: string]: unknown;
232
250
  }
233
251
 
252
+ export interface SandboxFileTreeNode {
253
+ path: string;
254
+ name: string;
255
+ type: "file" | "dir";
256
+ size?: number;
257
+ modified_at?: string;
258
+ children?: SandboxFileTreeNode[];
259
+ }
260
+
261
+ export interface SandboxWriteManyEntry {
262
+ path: string;
263
+ content: string | Uint8Array;
264
+ }
265
+
266
+ export interface SandboxWriteManyResult {
267
+ written: Array<{ path: string; size_bytes: number }>;
268
+ failed: Array<{ path: string; error: string }>;
269
+ }
270
+
271
+ export interface SandboxFileChange {
272
+ type: "created" | "modified" | "deleted";
273
+ path: string;
274
+ size_bytes?: number;
275
+ }
276
+
277
+ export interface SandboxEnvVar {
278
+ key: string;
279
+ encrypted: boolean;
280
+ value?: string;
281
+ }
282
+
234
283
  export interface SandboxFileList {
235
284
  path?: string;
236
285
  entries: SandboxFileEntry[];
@@ -328,6 +377,7 @@ function createBody(params: SandboxCreateParams = {}): Record<string, unknown> {
328
377
  region: params.region,
329
378
  entrypoint: params.entrypoint,
330
379
  tags: params.tags,
380
+ slug: params.slug,
331
381
  external_workspace_id:
332
382
  params.externalWorkspaceId ?? params.external_workspace_id,
333
383
  external_user_id: params.externalUserId ?? params.external_user_id,
@@ -403,6 +453,54 @@ export class SandboxFiles {
403
453
  download(path: string): Promise<Uint8Array> {
404
454
  return this.read(path);
405
455
  }
456
+
457
+ /** GET /api/v1/sandboxes/{id}/files/tree — recursive directory tree. */
458
+ async tree(path = "/workspace", depth = 3): Promise<SandboxFileTreeNode> {
459
+ const http = (this.sandbox as unknown as { http: HttpClient }).http;
460
+ const response = await http.get<unknown>(
461
+ `/sandboxes/${this.sandbox.id}/files/tree`,
462
+ { path, depth },
463
+ );
464
+ if (
465
+ response &&
466
+ typeof response === "object" &&
467
+ "data" in (response as object)
468
+ ) {
469
+ return (response as { data: SandboxFileTreeNode }).data;
470
+ }
471
+ return response as SandboxFileTreeNode;
472
+ }
473
+
474
+ /** POST /api/v1/sandboxes/{id}/files/write-many — write multiple files atomically. */
475
+ async writeMany(
476
+ files: SandboxWriteManyEntry[],
477
+ ): Promise<SandboxWriteManyResult> {
478
+ const http = (this.sandbox as unknown as { http: HttpClient }).http;
479
+ const payload = files.map((f) => ({
480
+ path: f.path,
481
+ content_base64: encodeContent(f.content),
482
+ }));
483
+ const response = await http.post<unknown>(
484
+ `/sandboxes/${this.sandbox.id}/files/write-many`,
485
+ { files: payload },
486
+ );
487
+ if (
488
+ response &&
489
+ typeof response === "object" &&
490
+ "data" in (response as object)
491
+ ) {
492
+ return (response as { data: SandboxWriteManyResult }).data;
493
+ }
494
+ return response as SandboxWriteManyResult;
495
+ }
496
+
497
+ /** GET /api/v1/sandboxes/{id}/files/watch (SSE) — live file change events. */
498
+ watch(): AsyncIterableIterator<SandboxFileChange> {
499
+ const http = (this.sandbox as unknown as { http: HttpClient }).http;
500
+ return http.stream<SandboxFileChange>(
501
+ `/sandboxes/${this.sandbox.id}/files/watch`,
502
+ );
503
+ }
406
504
  }
407
505
 
408
506
  export class SandboxPreview {
@@ -571,16 +669,53 @@ export class SandboxPreviews {
571
669
  export class SandboxEnv {
572
670
  constructor(private readonly sandbox: Sandbox) {}
573
671
 
574
- /**
575
- * Read-only listing of sandbox env vars.
576
- * The backend has no per-name CRUD route; use Sandbox.create(env=...) to set values.
577
- */
578
- async list(): Promise<Record<string, unknown>> {
579
- return unwrap(
580
- await (this.sandbox as unknown as { http: HttpClient }).http.get<
581
- WireEnvelope<Record<string, unknown>>
582
- >(`/sandboxes/${this.sandbox.id}/env`),
583
- ) as Record<string, unknown>;
672
+ private get http(): HttpClient {
673
+ return (this.sandbox as unknown as { http: HttpClient }).http;
674
+ }
675
+
676
+ /** GET /api/v1/sandboxes/{id}/env → list of env vars. */
677
+ async get(): Promise<SandboxEnvVar[]> {
678
+ const response = await this.http.get<unknown>(
679
+ `/sandboxes/${this.sandbox.id}/env`,
680
+ );
681
+ if (Array.isArray(response)) return response as SandboxEnvVar[];
682
+ if (response && typeof response === "object") {
683
+ const r = response as Record<string, unknown>;
684
+ for (const k of ["data", "vars", "env", "items"]) {
685
+ if (Array.isArray(r[k])) return r[k] as SandboxEnvVar[];
686
+ }
687
+ }
688
+ return [];
689
+ }
690
+
691
+ /** @deprecated Use get() */
692
+ async list(): Promise<SandboxEnvVar[]> {
693
+ return this.get();
694
+ }
695
+
696
+ /** PUT /api/v1/sandboxes/{id}/env — set (replace) env vars. */
697
+ async set(
698
+ vars: Array<{ key: string; value: string; encrypted?: boolean }>,
699
+ ): Promise<SandboxEnvVar[]> {
700
+ const response = await this.http.put<unknown>(
701
+ `/sandboxes/${this.sandbox.id}/env`,
702
+ { vars },
703
+ );
704
+ if (Array.isArray(response)) return response as SandboxEnvVar[];
705
+ if (response && typeof response === "object") {
706
+ const r = response as Record<string, unknown>;
707
+ for (const k of ["data", "vars", "env", "items"]) {
708
+ if (Array.isArray(r[k])) return r[k] as SandboxEnvVar[];
709
+ }
710
+ }
711
+ return [];
712
+ }
713
+
714
+ /** DELETE /api/v1/sandboxes/{id}/env/{key} — remove a single env var. */
715
+ async delete(key: string): Promise<void> {
716
+ await this.http.delete<unknown>(
717
+ `/sandboxes/${this.sandbox.id}/env/${encodeURIComponent(key)}`,
718
+ );
584
719
  }
585
720
  }
586
721
 
@@ -616,6 +751,12 @@ export class Sandbox {
616
751
  readonly env: SandboxEnv;
617
752
  /** Tag replacement. */
618
753
  readonly tags: SandboxTags;
754
+ /** Encrypted secrets + OAuth credentials scoped to this sandbox. */
755
+ readonly secrets: SandboxSecrets;
756
+ /** Egress allowlist + policies scoped to this sandbox. */
757
+ readonly network: SandboxNetwork;
758
+ /** Egress audit log + live tail scoped to this sandbox. */
759
+ readonly audit: SandboxAudit;
619
760
 
620
761
  constructor(
621
762
  private readonly http: HttpClient,
@@ -640,6 +781,11 @@ export class Sandbox {
640
781
  this.previews = new SandboxPreviews(this);
641
782
  this.env = new SandboxEnv(this);
642
783
  this.tags = new SandboxTags(this);
784
+ // Egress (security) namespaces — pre-scoped to this sandbox id.
785
+ const sandboxId = data.id as string;
786
+ this.secrets = new SandboxSecrets(http, sandboxId);
787
+ this.network = new SandboxNetwork(http, sandboxId);
788
+ this.audit = new SandboxAudit(http, sandboxId);
643
789
  }
644
790
 
645
791
  get id(): SandboxId {
@@ -823,6 +969,89 @@ export class Sandbox {
823
969
  await this.http.delete(`/sandboxes/${this.id}/snapshots/${snapshotId}`);
824
970
  }
825
971
 
972
+ /**
973
+ * Fork (clone) this sandbox into a new sandbox via copy-on-write snapshot.
974
+ * The original sandbox continues running unchanged.
975
+ */
976
+ async fork(
977
+ opts: { name?: string; metadata?: Record<string, unknown> } = {},
978
+ ): Promise<Sandbox> {
979
+ this.assertRunning("fork");
980
+ const body: Record<string, unknown> = {};
981
+ if (opts.name !== undefined) body.name = opts.name;
982
+ if (opts.metadata !== undefined) body.metadata = opts.metadata;
983
+ const data = unwrap(
984
+ await this.http.post<WireEnvelope<SandboxData>>(
985
+ `/sandboxes/${this.id}/fork`,
986
+ body,
987
+ ),
988
+ );
989
+ return new Sandbox(this.http, data);
990
+ }
991
+
992
+ /**
993
+ * PATCH /api/v1/sandboxes/{id} — update mutable sandbox fields.
994
+ */
995
+ async update(params: {
996
+ name?: string;
997
+ slug?: string;
998
+ tags?: string[];
999
+ metadata?: Record<string, unknown>;
1000
+ always_on?: boolean;
1001
+ timeout_sec?: number;
1002
+ idle_timeout_sec?: number;
1003
+ }): Promise<Sandbox> {
1004
+ const body: Record<string, unknown> = {};
1005
+ for (const [k, v] of Object.entries(params)) {
1006
+ if (v !== undefined) body[k] = v;
1007
+ }
1008
+ const data = unwrap(
1009
+ await this.http.patch<WireEnvelope<SandboxData>>(
1010
+ `/sandboxes/${this.id}`,
1011
+ body,
1012
+ ),
1013
+ );
1014
+ this.data = data;
1015
+ return this;
1016
+ }
1017
+
1018
+ /**
1019
+ * POST /api/v1/sandboxes/{id}/preview-token → {token, url, expires_at, scope}
1020
+ */
1021
+ async previewToken(
1022
+ expiresIn = 3600,
1023
+ scope = "read",
1024
+ ): Promise<{
1025
+ token: string;
1026
+ url: string;
1027
+ expires_at: string;
1028
+ scope: string;
1029
+ [key: string]: unknown;
1030
+ }> {
1031
+ const raw = await this.http.post<unknown>(
1032
+ `/sandboxes/${this.id}/preview-token`,
1033
+ { expires_in: expiresIn, scope },
1034
+ );
1035
+ if (raw && typeof raw === "object" && "data" in (raw as object)) {
1036
+ return (
1037
+ raw as {
1038
+ data: {
1039
+ token: string;
1040
+ url: string;
1041
+ expires_at: string;
1042
+ scope: string;
1043
+ };
1044
+ }
1045
+ ).data;
1046
+ }
1047
+ return raw as {
1048
+ token: string;
1049
+ url: string;
1050
+ expires_at: string;
1051
+ scope: string;
1052
+ };
1053
+ }
1054
+
826
1055
  async pause(): Promise<Sandbox> {
827
1056
  const data = unwrap(
828
1057
  await this.http.post<WireEnvelope<SandboxData>>(
@@ -49,6 +49,13 @@ export interface BucketCreateParams {
49
49
  visibility?: "private" | "public";
50
50
  quota_bytes?: number;
51
51
  public?: boolean;
52
+ // White-label attribution
53
+ externalWorkspaceId?: string;
54
+ external_workspace_id?: string;
55
+ externalUserId?: string;
56
+ external_user_id?: string;
57
+ externalProjectId?: string;
58
+ external_project_id?: string;
52
59
  [key: string]: unknown;
53
60
  }
54
61
 
@@ -0,0 +1,32 @@
1
+ /**
2
+ * TenantEvents — tenant-scoped SSE event stream.
3
+ * Corresponds to: GET /api/v1/events/stream?types=sandbox.*,webhook.delivered
4
+ */
5
+
6
+ import type { HttpClient } from "../http.js";
7
+
8
+ // ── Resource shapes ──────────────────────────────────────────────────────────
9
+
10
+ export interface TenantStreamEvent {
11
+ type: string;
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ // ── Main resource ─────────────────────────────────────────────────────────────
16
+
17
+ export class TenantEvents {
18
+ constructor(private readonly http: HttpClient) {}
19
+
20
+ /**
21
+ * GET /api/v1/events/stream — tenant-scoped SSE event stream.
22
+ *
23
+ * @param types - Event type globs to filter. Accepts a comma-separated string
24
+ * or an array, e.g. `["sandbox.*", "webhook.delivered"]`.
25
+ * Omit to receive all event types.
26
+ */
27
+ stream(types?: string | string[]): AsyncIterableIterator<TenantStreamEvent> {
28
+ const typesParam = Array.isArray(types) ? types.join(",") : types;
29
+ const query = typesParam ? `?types=${encodeURIComponent(typesParam)}` : "";
30
+ return this.http.stream<TenantStreamEvent>(`/events/stream${query}`);
31
+ }
32
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Tenant — current tenant info and plan/usage.
2
+ * Tenant — current tenant info, preview domain, and branding.
3
3
  */
4
4
 
5
5
  import type { HttpClient } from "../http.js";
@@ -14,22 +14,101 @@ export interface TenantPlan {
14
14
  [key: string]: unknown;
15
15
  }
16
16
 
17
+ export interface PreviewDomainData {
18
+ domain: string;
19
+ verified_at?: string | null;
20
+ cname_target?: string;
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ export interface PreviewDomainVerifyResult {
25
+ verified: boolean;
26
+ target?: string;
27
+ records?: unknown[];
28
+ [key: string]: unknown;
29
+ }
30
+
31
+ export interface BrandingData {
32
+ product_name?: string;
33
+ logo_url?: string;
34
+ support_url?: string;
35
+ support_email?: string;
36
+ primary_color?: string;
37
+ background_color?: string;
38
+ [key: string]: unknown;
39
+ }
40
+
17
41
  // ── Helpers ───────────────────────────────────────────────────────────────────
18
42
 
19
43
  function unwrap<T>(payload: unknown): T {
20
44
  if (payload && typeof payload === "object") {
21
45
  const p = payload as Record<string, unknown>;
22
- for (const k of ["data", "tenant", "items"]) {
46
+ for (const k of ["data", "tenant", "branding", "preview_domain", "items"]) {
23
47
  if (k in p) return p[k] as T;
24
48
  }
25
49
  }
26
50
  return payload as T;
27
51
  }
28
52
 
53
+ // ── Sub-resources ─────────────────────────────────────────────────────────────
54
+
55
+ export class PreviewDomain {
56
+ constructor(private readonly http: HttpClient) {}
57
+
58
+ /** GET /api/v1/tenant/preview-domain → {domain, verified_at, cname_target} */
59
+ async get(): Promise<PreviewDomainData> {
60
+ return unwrap(await this.http.get<unknown>("/tenant/preview-domain"));
61
+ }
62
+
63
+ /** PUT /api/v1/tenant/preview-domain — set the preview domain. */
64
+ async set(domain: string): Promise<PreviewDomainData> {
65
+ return unwrap(
66
+ await this.http.put<unknown>("/tenant/preview-domain", { domain }),
67
+ );
68
+ }
69
+
70
+ /** POST /api/v1/tenant/preview-domain/verify → {verified, target, records} */
71
+ async verify(): Promise<PreviewDomainVerifyResult> {
72
+ return unwrap(
73
+ await this.http.post<unknown>("/tenant/preview-domain/verify", {}),
74
+ );
75
+ }
76
+
77
+ /** DELETE /api/v1/tenant/preview-domain */
78
+ async delete(): Promise<void> {
79
+ await this.http.delete<unknown>("/tenant/preview-domain");
80
+ }
81
+ }
82
+
83
+ export class Branding {
84
+ constructor(private readonly http: HttpClient) {}
85
+
86
+ /** GET /api/v1/tenant/branding */
87
+ async get(): Promise<BrandingData> {
88
+ return unwrap(await this.http.get<unknown>("/tenant/branding"));
89
+ }
90
+
91
+ /** PUT /api/v1/tenant/branding — keys: product_name, logo_url, support_url, support_email, primary_color, background_color */
92
+ async set(branding: BrandingData): Promise<BrandingData> {
93
+ return unwrap(await this.http.put<unknown>("/tenant/branding", branding));
94
+ }
95
+
96
+ /** DELETE /api/v1/tenant/branding */
97
+ async delete(): Promise<void> {
98
+ await this.http.delete<unknown>("/tenant/branding");
99
+ }
100
+ }
101
+
29
102
  // ── Main resource ─────────────────────────────────────────────────────────────
30
103
 
31
104
  export class Tenant {
32
- constructor(private readonly http: HttpClient) {}
105
+ readonly preview_domain: PreviewDomain;
106
+ readonly branding: Branding;
107
+
108
+ constructor(private readonly http: HttpClient) {
109
+ this.preview_domain = new PreviewDomain(http);
110
+ this.branding = new Branding(http);
111
+ }
33
112
 
34
113
  /** Get the current tenant's plan, limits, and live usage counters. */
35
114
  async current(): Promise<TenantPlan> {
@@ -40,6 +40,13 @@ export interface VolumeCreateParams {
40
40
  sizeGb?: number;
41
41
  region?: string;
42
42
  idempotencyKey?: string;
43
+ // White-label attribution
44
+ externalWorkspaceId?: string;
45
+ external_workspace_id?: string;
46
+ externalUserId?: string;
47
+ external_user_id?: string;
48
+ externalProjectId?: string;
49
+ external_project_id?: string;
43
50
  [key: string]: unknown;
44
51
  }
45
52
 
@@ -2,10 +2,48 @@
2
2
  * Webhooks resource — tenant outgoing event delivery.
3
3
  */
4
4
 
5
+ import { createHmac, timingSafeEqual } from "node:crypto";
5
6
  import { randomUUID } from "node:crypto";
6
7
 
7
8
  import type { HttpClient } from "../http.js";
8
9
 
10
+ const MAX_TIMESTAMP_AGE_MS = 5 * 60 * 1000;
11
+
12
+ /**
13
+ * Verify an incoming ``X-Miosa-Signature`` header.
14
+ * Header format: ``t=<unix_ts>,v1=<hex_hmac>``
15
+ */
16
+ export function verifySignature(
17
+ payload: string | Uint8Array,
18
+ signatureHeader: string,
19
+ secret: string,
20
+ ): boolean {
21
+ const parts: Record<string, string> = {};
22
+ for (const part of signatureHeader.split(",")) {
23
+ const idx = part.indexOf("=");
24
+ if (idx > 0) parts[part.slice(0, idx).trim()] = part.slice(idx + 1).trim();
25
+ }
26
+ const tsStr = parts["t"];
27
+ const sig = parts["v1"];
28
+ if (!tsStr || !sig) return false;
29
+
30
+ const ts = parseInt(tsStr, 10);
31
+ if (isNaN(ts)) return false;
32
+ if (Date.now() - ts * 1000 > MAX_TIMESTAMP_AGE_MS) {
33
+ throw new Error(`Webhook timestamp is too old: ${ts}`);
34
+ }
35
+
36
+ const body =
37
+ payload instanceof Uint8Array ? payload : Buffer.from(payload, "utf-8");
38
+ const signed = Buffer.concat([Buffer.from(`${tsStr}.`, "utf-8"), body]);
39
+ const expected = createHmac("sha256", secret).update(signed).digest("hex");
40
+
41
+ return timingSafeEqual(
42
+ Buffer.from(expected, "utf-8"),
43
+ Buffer.from(sig, "utf-8"),
44
+ );
45
+ }
46
+
9
47
  // ── Branded IDs ──────────────────────────────────────────────────────────────
10
48
 
11
49
  export type WebhookId = string & { readonly __brand: "WebhookId" };
@@ -168,4 +206,21 @@ export class Webhooks {
168
206
  "items",
169
207
  ]);
170
208
  }
209
+
210
+ /**
211
+ * Verify an incoming ``X-Miosa-Signature`` header.
212
+ *
213
+ * Header format: ``t=<unix_ts>,v1=<hex_hmac>``
214
+ * HMAC body: ``<t>.<raw_payload>``
215
+ *
216
+ * Throws if timestamp is older than 5 minutes.
217
+ * Returns true if signature matches.
218
+ */
219
+ static verifySignature(
220
+ payload: string | Uint8Array,
221
+ signatureHeader: string,
222
+ secret: string,
223
+ ): boolean {
224
+ return verifySignature(payload, signatureHeader, secret);
225
+ }
171
226
  }