@miosa/sdk 1.2.1 → 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.1",
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,
@@ -345,4 +345,15 @@ export class Admin {
345
345
  model_id: modelId,
346
346
  });
347
347
  }
348
+
349
+ /** POST /api/v1/admin/impersonate — returns {token, expires_at}. */
350
+ impersonate(
351
+ externalUserId: string,
352
+ options: { ttlSec?: number } = {},
353
+ ): Promise<{ token: string; expires_at: string }> {
354
+ return this.http.post("/admin/impersonate", {
355
+ external_user_id: externalUserId,
356
+ ttl_sec: options.ttlSec ?? 3600,
357
+ });
358
+ }
348
359
  }
@@ -113,6 +113,22 @@ export class ApiKeys {
113
113
  return unwrap<ApiKeyCreateResult>(data);
114
114
  }
115
115
 
116
+ /** POST /api/v1/api-keys/scoped — L2 delegation token bound to one external user. */
117
+ async createScoped(params: {
118
+ externalUserId: string;
119
+ scopes: string[];
120
+ expiresAt?: string;
121
+ }): Promise<ApiKeyCreateResult> {
122
+ const body = stripUndefined({
123
+ external_user_id: params.externalUserId,
124
+ scopes: params.scopes,
125
+ expires_at: params.expiresAt,
126
+ });
127
+ return unwrap<ApiKeyCreateResult>(
128
+ await this.http.post<unknown>("/api-keys/scoped", body),
129
+ );
130
+ }
131
+
116
132
  async delete(keyId: string): Promise<void> {
117
133
  await this.http.delete<unknown>(`/api-keys/${keyId}`);
118
134
  }
@@ -41,7 +41,7 @@ export interface CustomDomainRegisterParams {
41
41
  * // 1. Register the domain
42
42
  * const domain = await computer.domains.register("app.example.com");
43
43
  * console.log(domain.instructions);
44
- * // => "Add a CNAME record: app.example.com → <slug>.sandbox.<tenant-domain>"
44
+ * // => "Add a CNAME record: app.example.com → <slug>.sandbox.miosa.ai"
45
45
  *
46
46
  * // 2. Add the CNAME in your DNS registrar, then...
47
47
  *
@@ -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,
@@ -0,0 +1,355 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { HttpClient } from "../http.js";
3
+ import {
4
+ BillingResource,
5
+ BulkResource,
6
+ ExternalUsers,
7
+ GovernanceTenant,
8
+ GovernanceWorkspaces,
9
+ } from "./governance.js";
10
+ import { ApiKeys } from "./api-keys.js";
11
+ import { Admin } from "./admin.js";
12
+
13
+ const mockGet = vi.fn();
14
+ const mockPost = vi.fn();
15
+ const mockPut = vi.fn();
16
+ const mockPatch = vi.fn();
17
+ const mockDelete = vi.fn();
18
+ const mockStream = vi.fn();
19
+ const mockRequest = vi.fn();
20
+
21
+ function makeHttp(): HttpClient {
22
+ return {
23
+ get: mockGet,
24
+ post: mockPost,
25
+ put: mockPut,
26
+ patch: mockPatch,
27
+ delete: mockDelete,
28
+ request: mockRequest,
29
+ stream: mockStream,
30
+ } as unknown as HttpClient;
31
+ }
32
+
33
+ beforeEach(() => {
34
+ vi.resetAllMocks();
35
+ });
36
+
37
+ // ── GovernanceTenant.policy ───────────────────────────────────────────────────
38
+
39
+ describe("GovernanceTenant.policy", () => {
40
+ it("get calls GET /tenant/policy and unwraps data", async () => {
41
+ mockGet.mockResolvedValue({ data: { quotas: { max_sandboxes: 10 } } });
42
+ const t = new GovernanceTenant(makeHttp());
43
+ const result = await t.policy.get();
44
+ expect(mockGet).toHaveBeenCalledWith("/tenant/policy");
45
+ expect((result as Record<string, unknown>).quotas).toBeDefined();
46
+ });
47
+
48
+ it("set calls PUT /tenant/policy with body", async () => {
49
+ mockPut.mockResolvedValue({ data: { quotas: { max_sandboxes: 5 } } });
50
+ const t = new GovernanceTenant(makeHttp());
51
+ await t.policy.set({ quotas: { max_sandboxes: 5 } });
52
+ expect(mockPut).toHaveBeenCalledWith("/tenant/policy", {
53
+ quotas: { max_sandboxes: 5 },
54
+ });
55
+ });
56
+
57
+ it("delete calls DELETE /tenant/policy", async () => {
58
+ mockDelete.mockResolvedValue(null);
59
+ const t = new GovernanceTenant(makeHttp());
60
+ await t.policy.delete();
61
+ expect(mockDelete).toHaveBeenCalledWith("/tenant/policy");
62
+ });
63
+ });
64
+
65
+ // ── GovernanceTenant.members ──────────────────────────────────────────────────
66
+
67
+ describe("GovernanceTenant.members", () => {
68
+ it("list returns members array", async () => {
69
+ mockGet.mockResolvedValue({
70
+ data: [{ id: "m1", email: "a@b.com", role: "admin" }],
71
+ });
72
+ const t = new GovernanceTenant(makeHttp());
73
+ const members = await t.members.list();
74
+ expect(members).toHaveLength(1);
75
+ expect(members[0].role).toBe("admin");
76
+ });
77
+
78
+ it("invite posts email and role", async () => {
79
+ mockPost.mockResolvedValue({ data: { id: "m2", role: "developer" } });
80
+ const t = new GovernanceTenant(makeHttp());
81
+ const result = await t.members.invite("x@y.com", "developer");
82
+ expect(mockPost).toHaveBeenCalledWith("/tenant/members", {
83
+ email: "x@y.com",
84
+ role: "developer",
85
+ });
86
+ expect(result.role).toBe("developer");
87
+ });
88
+
89
+ it("updateRole patches member role", async () => {
90
+ mockPatch.mockResolvedValue({ data: { id: "m1", role: "admin" } });
91
+ const t = new GovernanceTenant(makeHttp());
92
+ await t.members.updateRole("m1", "admin");
93
+ expect(mockPatch).toHaveBeenCalledWith("/tenant/members/m1/role", {
94
+ role: "admin",
95
+ });
96
+ });
97
+
98
+ it("remove deletes member", async () => {
99
+ mockDelete.mockResolvedValue(null);
100
+ const t = new GovernanceTenant(makeHttp());
101
+ await t.members.remove("m1");
102
+ expect(mockDelete).toHaveBeenCalledWith("/tenant/members/m1");
103
+ });
104
+
105
+ it("transferOwnership posts new_owner_user_id", async () => {
106
+ mockPost.mockResolvedValue({ data: { transferred: true } });
107
+ const t = new GovernanceTenant(makeHttp());
108
+ await t.members.transferOwnership("user_new");
109
+ expect(mockPost).toHaveBeenCalledWith("/tenant/transfer-ownership", {
110
+ new_owner_user_id: "user_new",
111
+ });
112
+ });
113
+ });
114
+
115
+ // ── GovernanceWorkspaces ──────────────────────────────────────────────────────
116
+
117
+ describe("GovernanceWorkspaces", () => {
118
+ it("list returns workspace array", async () => {
119
+ mockGet.mockResolvedValue({ data: [{ id: "ws_1", name: "alpha" }] });
120
+ const gw = new GovernanceWorkspaces(makeHttp());
121
+ const items = await gw.list();
122
+ expect(items).toHaveLength(1);
123
+ expect(items[0].id).toBe("ws_1");
124
+ });
125
+
126
+ it("create posts name", async () => {
127
+ mockPost.mockResolvedValue({ data: { id: "ws_2", name: "beta" } });
128
+ const gw = new GovernanceWorkspaces(makeHttp());
129
+ await gw.create("beta");
130
+ expect(mockPost).toHaveBeenCalledWith("/workspaces", { name: "beta" });
131
+ });
132
+
133
+ it("workspace(id).policy.get calls correct path", async () => {
134
+ mockGet.mockResolvedValue({ data: { quotas: {} } });
135
+ const gw = new GovernanceWorkspaces(makeHttp());
136
+ await gw.workspace("ws_1").policy.get();
137
+ expect(mockGet).toHaveBeenCalledWith("/workspaces/ws_1/policy");
138
+ });
139
+
140
+ it("workspace(id).policy.set calls PUT", async () => {
141
+ mockPut.mockResolvedValue({ data: {} });
142
+ const gw = new GovernanceWorkspaces(makeHttp());
143
+ await gw.workspace("ws_1").policy.set({ quotas: { max_sandboxes: 3 } });
144
+ expect(mockPut).toHaveBeenCalledWith("/workspaces/ws_1/policy", {
145
+ quotas: { max_sandboxes: 3 },
146
+ });
147
+ });
148
+
149
+ it("workspace(id).members.list calls correct path", async () => {
150
+ mockGet.mockResolvedValue({ data: [{ id: "m1", role: "developer" }] });
151
+ const gw = new GovernanceWorkspaces(makeHttp());
152
+ const members = await gw.workspace("ws_1").members.list();
153
+ expect(mockGet).toHaveBeenCalledWith("/workspaces/ws_1/members");
154
+ expect(members[0].role).toBe("developer");
155
+ });
156
+
157
+ it("workspace(id).members.invite posts correctly", async () => {
158
+ mockPost.mockResolvedValue({ data: { id: "m2" } });
159
+ const gw = new GovernanceWorkspaces(makeHttp());
160
+ await gw.workspace("ws_1").members.invite("bob@acme.com", "viewer");
161
+ expect(mockPost).toHaveBeenCalledWith("/workspaces/ws_1/members", {
162
+ email: "bob@acme.com",
163
+ role: "viewer",
164
+ });
165
+ });
166
+
167
+ it("workspace(id).transfer posts resource_ids and target", async () => {
168
+ mockPost.mockResolvedValue({ data: { transferred: 2 } });
169
+ const gw = new GovernanceWorkspaces(makeHttp());
170
+ await gw.workspace("ws_1").transfer(["sbx_1", "sbx_2"], "ws_2");
171
+ expect(mockPost).toHaveBeenCalledWith("/workspaces/ws_1/transfer", {
172
+ resource_ids: ["sbx_1", "sbx_2"],
173
+ target_workspace_id: "ws_2",
174
+ });
175
+ });
176
+ });
177
+
178
+ // ── ExternalUsers.policy ──────────────────────────────────────────────────────
179
+
180
+ describe("ExternalUsers.policy", () => {
181
+ it("get calls correct path", async () => {
182
+ mockGet.mockResolvedValue({ data: { lifecycle: {} } });
183
+ const eu = new ExternalUsers(makeHttp());
184
+ await eu.call("alice").policy.get();
185
+ expect(mockGet).toHaveBeenCalledWith("/external-users/alice/policy");
186
+ });
187
+
188
+ it("effective returns typed EffectivePolicyDoc with source fields", async () => {
189
+ mockGet.mockResolvedValue({
190
+ lifecycle: {
191
+ default_idle_timeout_sec: { value: 600, source: "user" },
192
+ default_timeout_sec: { value: 86400, source: "tenant" },
193
+ },
194
+ quotas: {
195
+ max_sandboxes: { value: 5, source: "workspace" },
196
+ },
197
+ });
198
+ const eu = new ExternalUsers(makeHttp());
199
+ const eff = await eu.call("alice").policy.effective();
200
+ expect(eff.lifecycle["default_idle_timeout_sec"]?.value).toBe(600);
201
+ expect(eff.lifecycle["default_idle_timeout_sec"]?.source).toBe("user");
202
+ expect(eff.quotas["max_sandboxes"]?.value).toBe(5);
203
+ expect(eff.quotas["max_sandboxes"]?.source).toBe("workspace");
204
+ });
205
+ });
206
+
207
+ // ── BulkResource ──────────────────────────────────────────────────────────────
208
+
209
+ describe("BulkResource", () => {
210
+ it("sandboxes.pause posts ids", async () => {
211
+ mockPost.mockResolvedValue({ queued: 3, job_id: "job_1" });
212
+ const bulk = new BulkResource(makeHttp());
213
+ const result = await bulk.sandboxes.pause({
214
+ ids: ["sbx_1", "sbx_2", "sbx_3"],
215
+ });
216
+ expect(mockPost).toHaveBeenCalledWith("/bulk/sandboxes/pause", {
217
+ ids: ["sbx_1", "sbx_2", "sbx_3"],
218
+ });
219
+ expect(result.job_id).toBe("job_1");
220
+ });
221
+
222
+ it("sandboxes.destroy posts filter", async () => {
223
+ mockPost.mockResolvedValue({ queued: 5, job_id: "job_2" });
224
+ const bulk = new BulkResource(makeHttp());
225
+ const result = await bulk.sandboxes.destroy({ filter: { state: "idle" } });
226
+ expect(mockPost).toHaveBeenCalledWith("/bulk/sandboxes/destroy", {
227
+ filter: { state: "idle" },
228
+ });
229
+ expect(result.queued).toBe(5);
230
+ });
231
+
232
+ it("policy.apply posts tier + ids + policy", async () => {
233
+ mockPost.mockResolvedValue({ queued: 10, job_id: "job_3" });
234
+ const bulk = new BulkResource(makeHttp());
235
+ await bulk.policy.apply({
236
+ tier: "external_user",
237
+ idsOrFilter: ["u1", "u2"],
238
+ policy: { quotas: { max_sandboxes: 3 } },
239
+ });
240
+ expect(mockPost).toHaveBeenCalledWith("/bulk/policy/apply", {
241
+ tier: "external_user",
242
+ ids: ["u1", "u2"],
243
+ policy: { quotas: { max_sandboxes: 3 } },
244
+ });
245
+ });
246
+
247
+ it("jobs.get returns job status", async () => {
248
+ mockGet.mockResolvedValue({
249
+ data: { id: "job_1", status: "completed", processed: 3 },
250
+ });
251
+ const bulk = new BulkResource(makeHttp());
252
+ const job = await bulk.jobs.get("job_1");
253
+ expect(job.status).toBe("completed");
254
+ expect(job.processed).toBe(3);
255
+ });
256
+ });
257
+
258
+ // ── BillingResource ───────────────────────────────────────────────────────────
259
+
260
+ describe("BillingResource", () => {
261
+ it("invoices.list returns array", async () => {
262
+ mockGet.mockResolvedValue({
263
+ data: [{ id: "inv_1", amount: 5000 }],
264
+ });
265
+ const billing = new BillingResource(makeHttp());
266
+ const invoices = await billing.invoices.list();
267
+ expect(invoices[0].id).toBe("inv_1");
268
+ });
269
+
270
+ it("invoices.get returns single invoice", async () => {
271
+ mockGet.mockResolvedValue({
272
+ data: { id: "inv_1", amount: 5000, line_items: [] },
273
+ });
274
+ const billing = new BillingResource(makeHttp());
275
+ const inv = await billing.invoices.get("inv_1");
276
+ expect(inv.id).toBe("inv_1");
277
+ });
278
+
279
+ it("paymentMethods returns list", async () => {
280
+ mockGet.mockResolvedValue({
281
+ data: [{ id: "pm_1", brand: "visa", last4: "4242" }],
282
+ });
283
+ const billing = new BillingResource(makeHttp());
284
+ const methods = await billing.paymentMethods();
285
+ expect(methods[0].brand).toBe("visa");
286
+ });
287
+
288
+ it("upcoming returns preview", async () => {
289
+ mockGet.mockResolvedValue({
290
+ data: { amount: 1200, period_end: "2026-06-01" },
291
+ });
292
+ const billing = new BillingResource(makeHttp());
293
+ const upcoming = await billing.upcoming();
294
+ expect((upcoming as Record<string, unknown>).amount).toBe(1200);
295
+ });
296
+ });
297
+
298
+ // ── ApiKeys.createScoped ──────────────────────────────────────────────────────
299
+
300
+ describe("ApiKeys.createScoped", () => {
301
+ it("posts to /api-keys/scoped with correct body", async () => {
302
+ mockPost.mockResolvedValue({
303
+ data: { id: "key_1", token: "msk_l2_abc" },
304
+ });
305
+ const keys = new ApiKeys(makeHttp());
306
+ const result = await keys.createScoped({
307
+ externalUserId: "alice-42",
308
+ scopes: ["sandboxes:read", "sandboxes:exec"],
309
+ expiresAt: "2026-12-31T00:00:00Z",
310
+ });
311
+ expect(mockPost).toHaveBeenCalledWith("/api-keys/scoped", {
312
+ external_user_id: "alice-42",
313
+ scopes: ["sandboxes:read", "sandboxes:exec"],
314
+ expires_at: "2026-12-31T00:00:00Z",
315
+ });
316
+ expect(result.token).toBe("msk_l2_abc");
317
+ });
318
+
319
+ it("omits expires_at when not provided", async () => {
320
+ mockPost.mockResolvedValue({ data: { id: "key_2" } });
321
+ const keys = new ApiKeys(makeHttp());
322
+ await keys.createScoped({
323
+ externalUserId: "bob",
324
+ scopes: ["sandboxes:read"],
325
+ });
326
+ const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
327
+ expect(body["expires_at"]).toBeUndefined();
328
+ });
329
+ });
330
+
331
+ // ── Admin.impersonate ─────────────────────────────────────────────────────────
332
+
333
+ describe("Admin.impersonate", () => {
334
+ it("posts to /admin/impersonate with external_user_id and ttl_sec", async () => {
335
+ mockPost.mockResolvedValue({
336
+ token: "msi_abc",
337
+ expires_at: "2026-06-01T00:00:00Z",
338
+ });
339
+ const admin = new Admin(makeHttp());
340
+ const result = await admin.impersonate("alice-42", { ttlSec: 1800 });
341
+ expect(mockPost).toHaveBeenCalledWith("/admin/impersonate", {
342
+ external_user_id: "alice-42",
343
+ ttl_sec: 1800,
344
+ });
345
+ expect(result.token).toBe("msi_abc");
346
+ });
347
+
348
+ it("defaults ttl_sec to 3600", async () => {
349
+ mockPost.mockResolvedValue({ token: "msi_xyz" });
350
+ const admin = new Admin(makeHttp());
351
+ await admin.impersonate("bob");
352
+ const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
353
+ expect(body["ttl_sec"]).toBe(3600);
354
+ });
355
+ });