@miosa/sdk 1.1.0 → 1.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miosa/sdk",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
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/client.ts CHANGED
@@ -44,7 +44,6 @@ import { Volumes } from "./resources/volumes.js";
44
44
  import { Webhooks } from "./resources/webhooks.js";
45
45
  import { WorkspaceInvites } from "./resources/workspace-invites.js";
46
46
  import { WorkspaceMembers } from "./resources/workspace-members.js";
47
- import { Workspaces } from "./resources/workspaces.js";
48
47
  import type { MiosaClientConfig } from "./types.js";
49
48
 
50
49
  const DEFAULT_BASE_URL = "https://api.miosa.ai/api/v1";
@@ -65,9 +64,6 @@ const DEFAULT_MAX_RETRIES = 3;
65
64
  * ```
66
65
  */
67
66
  export class Miosa {
68
- /** Workspace CRUD — create, list, get, update, delete, and sub-resource queries. */
69
- readonly workspaces: Workspaces;
70
-
71
67
  /** Per-workspace user roster — list, add, update role, remove. */
72
68
  readonly workspaceMembers: WorkspaceMembers;
73
69
 
@@ -242,7 +238,6 @@ export class Miosa {
242
238
  maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
243
239
  });
244
240
 
245
- this.workspaces = new Workspaces(this.http);
246
241
  this.workspaceMembers = new WorkspaceMembers(this.http);
247
242
  this.workspaceInvites = new WorkspaceInvites(this.http);
248
243
  this.orgInvites = new OrgInvites(this.http);
package/src/index.ts CHANGED
@@ -245,16 +245,6 @@ export type {
245
245
  ApiKeyCreateParams,
246
246
  } from "./resources/api-keys.js";
247
247
 
248
- // Workspace CRUD
249
- export { Workspaces } from "./resources/workspaces.js";
250
- export type {
251
- WorkspaceId as TenantWorkspaceId,
252
- WorkspaceData,
253
- WorkspaceCreateParams,
254
- WorkspaceUpdateParams,
255
- WorkspaceComputerTemplateCreateParams,
256
- } from "./resources/workspaces.js";
257
-
258
248
  // Members & Invites
259
249
  export { WorkspaceMembers } from "./resources/workspace-members.js";
260
250
  export type {
@@ -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
  }
@@ -47,9 +47,6 @@ export interface ApiKeyCreateParams {
47
47
  expires_at?: string;
48
48
  expiresAt?: string;
49
49
  idempotencyKey?: string;
50
- /** Scope this key to a single workspace. Omit for a tenant-wide key. */
51
- workspaceId?: string;
52
- workspace_id?: string;
53
50
  [key: string]: unknown;
54
51
  }
55
52
 
@@ -103,11 +100,10 @@ export class ApiKeys {
103
100
  }
104
101
 
105
102
  async create(params: ApiKeyCreateParams): Promise<ApiKeyCreateResult> {
106
- const { idempotencyKey: ikey, expiresAt, workspaceId, ...rest } = params;
103
+ const { idempotencyKey: ikey, expiresAt, ...rest } = params;
107
104
  const body = stripUndefined({
108
105
  ...rest,
109
106
  expires_at: expiresAt ?? rest.expires_at,
110
- workspace_id: workspaceId ?? rest.workspace_id,
111
107
  });
112
108
  const data = await this.http.request<unknown>("/api-keys", {
113
109
  method: "POST",
@@ -117,6 +113,22 @@ export class ApiKeys {
117
113
  return unwrap<ApiKeyCreateResult>(data);
118
114
  }
119
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
+
120
132
  async delete(keyId: string): Promise<void> {
121
133
  await this.http.delete<unknown>(`/api-keys/${keyId}`);
122
134
  }
@@ -55,13 +55,6 @@ export interface CronJobCreateParams {
55
55
  name: string;
56
56
  schedule: string;
57
57
  idempotencyKey?: string;
58
- // White-label attribution
59
- externalWorkspaceId?: string;
60
- external_workspace_id?: string;
61
- externalUserId?: string;
62
- external_user_id?: string;
63
- externalProjectId?: string;
64
- external_project_id?: string;
65
58
  [key: string]: unknown;
66
59
  }
67
60
 
@@ -27,13 +27,6 @@ export interface CustomDomainData {
27
27
 
28
28
  export interface CustomDomainRegisterParams {
29
29
  fqdn: string;
30
- // White-label attribution
31
- externalWorkspaceId?: string;
32
- external_workspace_id?: string;
33
- externalUserId?: string;
34
- external_user_id?: string;
35
- externalProjectId?: string;
36
- external_project_id?: string;
37
30
  }
38
31
 
39
32
  // ─── CustomDomains resource ──────────────────────────────────────────────────
@@ -95,7 +95,7 @@ export interface DeploymentData {
95
95
  * with `source_sandbox_id` on the version row. Will become nullable.
96
96
  */
97
97
  repo_url?: string;
98
- repo_provider?: "github" | "gitlab" | "bitbucket";
98
+ repo_provider?: "github";
99
99
  branch?: string;
100
100
  build_command?: string | null;
101
101
  run_command?: string | null;
@@ -278,9 +278,6 @@ export interface DeploymentCreateParams extends ExternalAttribution {
278
278
  autoDeploy?: boolean;
279
279
  database?: DeploymentDatabaseRequest;
280
280
  metadata?: Record<string, unknown>;
281
- /** Pin build and runtime VMs to a specific DC. One of: "us-west", "us-east", "us-mia". */
282
- target_region?: string;
283
- targetRegion?: string;
284
281
  idempotencyKey?: string;
285
282
  }
286
283
 
@@ -620,7 +617,6 @@ export class Deployments {
620
617
  auto_deploy: params.autoDeploy ?? params.auto_deploy,
621
618
  database: params.database,
622
619
  metadata: params.metadata,
623
- target_region: params.targetRegion ?? params.target_region,
624
620
  ...attributionBody(params),
625
621
  });
626
622
  const data = await this.http.request<unknown>("/deployments", {
@@ -739,16 +735,9 @@ export class Deployments {
739
735
  return unwrap(data) as DeploymentBuildData;
740
736
  }
741
737
 
742
- async listEnv(
743
- deploymentId: string,
744
- opts: { environment?: string } = {},
745
- ): Promise<Record<string, unknown>[]> {
746
- const query = stripUndefined({ environment: opts.environment });
738
+ async listEnv(deploymentId: string): Promise<Record<string, unknown>[]> {
747
739
  const data = await this.http.get<unknown>(
748
740
  `/deployments/${deploymentId}/env`,
749
- Object.keys(query).length
750
- ? (query as Record<string, string | number | boolean | undefined>)
751
- : undefined,
752
741
  );
753
742
  return listItems<Record<string, unknown>>(data);
754
743
  }
@@ -47,13 +47,6 @@ export interface CustomDomainCreateParams {
47
47
  redirect_policy?: "none" | "www_to_apex" | "apex_to_www";
48
48
  redirectPolicy?: "none" | "www_to_apex" | "apex_to_www";
49
49
  idempotencyKey?: string;
50
- // White-label attribution
51
- externalWorkspaceId?: string;
52
- external_workspace_id?: string;
53
- externalUserId?: string;
54
- external_user_id?: string;
55
- externalProjectId?: string;
56
- external_project_id?: string;
57
50
  [key: string]: unknown;
58
51
  }
59
52
 
@@ -46,13 +46,6 @@ export interface FunctionCreateParams {
46
46
  timeoutSec?: number;
47
47
  env?: Record<string, string>;
48
48
  idempotencyKey?: string;
49
- // White-label attribution
50
- externalWorkspaceId?: string;
51
- external_workspace_id?: string;
52
- externalUserId?: string;
53
- external_user_id?: string;
54
- externalProjectId?: string;
55
- external_project_id?: string;
56
49
  [key: string]: unknown;
57
50
  }
58
51
 
@@ -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
+ });