@miosa/sdk 1.0.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/dist/index.d.ts +968 -6
- package/dist/index.js +1341 -236
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +39 -0
- package/src/index.ts +90 -0
- package/src/resources/admin.ts +11 -0
- package/src/resources/api-keys.ts +16 -0
- package/src/resources/computer.ts +16 -0
- package/src/resources/egress.test.ts +318 -0
- package/src/resources/egressAudit.ts +245 -0
- package/src/resources/egressNetwork.ts +450 -0
- package/src/resources/egressSecrets.ts +577 -0
- package/src/resources/governance.test.ts +355 -0
- package/src/resources/governance.ts +528 -0
- package/src/resources/org-invites.ts +189 -0
- package/src/resources/phase1.test.ts +187 -0
- package/src/resources/quotas.ts +77 -0
- package/src/resources/sandbox-processes.ts +112 -0
- package/src/resources/sandbox-shares.ts +83 -0
- package/src/resources/sandboxes.ts +239 -10
- package/src/resources/tenant-events.ts +32 -0
- package/src/resources/workspace-invites.ts +188 -0
- package/src/resources/workspace-members.test.ts +121 -0
- package/src/resources/workspace-members.ts +143 -0
- package/src/resources/workspaces.ts +285 -0
|
@@ -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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
) as
|
|
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>>(
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace Invites — email invite flow for workspace access.
|
|
3
|
+
*
|
|
4
|
+
* Sending an invite to an email that already belongs to a tenant member
|
|
5
|
+
* short-circuits to directly adding that user (returns `type: "added"`).
|
|
6
|
+
* Accepting a workspace invite for an unknown email auto-creates both a
|
|
7
|
+
* `tenant_members` and a `workspace_members` row atomically.
|
|
8
|
+
*
|
|
9
|
+
* Public endpoints (no auth):
|
|
10
|
+
* GET /workspace-invites/:token
|
|
11
|
+
*
|
|
12
|
+
* Authenticated endpoints:
|
|
13
|
+
* POST /workspaces/:id/invites
|
|
14
|
+
* GET /workspaces/:id/invites
|
|
15
|
+
* DELETE /workspaces/:id/invites/:invite_id
|
|
16
|
+
* POST /workspace-invites/:token/accept
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { HttpClient } from "../http.js";
|
|
20
|
+
import type {
|
|
21
|
+
WorkspaceMemberRecord,
|
|
22
|
+
WorkspaceRole,
|
|
23
|
+
} from "./workspace-members.js";
|
|
24
|
+
|
|
25
|
+
// ── Resource shapes ──────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface WorkspaceInvite {
|
|
28
|
+
id: string;
|
|
29
|
+
workspace_id: string;
|
|
30
|
+
tenant_id: string;
|
|
31
|
+
email: string;
|
|
32
|
+
role: WorkspaceRole;
|
|
33
|
+
invited_by: string | null;
|
|
34
|
+
expires_at: string;
|
|
35
|
+
accepted_at: string | null;
|
|
36
|
+
inserted_at: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface WorkspaceInvitePreview {
|
|
40
|
+
workspace_name: string;
|
|
41
|
+
tenant_name: string;
|
|
42
|
+
role: WorkspaceRole;
|
|
43
|
+
email: string;
|
|
44
|
+
expires_at: string;
|
|
45
|
+
expired: boolean;
|
|
46
|
+
revoked: boolean;
|
|
47
|
+
accepted: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Request payloads ─────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export interface CreateWorkspaceInviteParams {
|
|
53
|
+
email: string;
|
|
54
|
+
role?: WorkspaceRole;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Response shapes ───────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/** Returned when the email was unknown — an invite was created. */
|
|
60
|
+
export interface WorkspaceInviteCreatedResponse {
|
|
61
|
+
data: WorkspaceInvite;
|
|
62
|
+
type: "invited";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Returned when the email already had a tenant_members row — added directly. */
|
|
66
|
+
export interface WorkspaceMemberAddedResponse {
|
|
67
|
+
data: WorkspaceMemberRecord;
|
|
68
|
+
type: "added";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type CreateWorkspaceInviteResponse =
|
|
72
|
+
| WorkspaceInviteCreatedResponse
|
|
73
|
+
| WorkspaceMemberAddedResponse;
|
|
74
|
+
|
|
75
|
+
export interface WorkspaceInviteListResponse {
|
|
76
|
+
data: WorkspaceInvite[];
|
|
77
|
+
total: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface WorkspaceInviteRevokeResponse {
|
|
81
|
+
invite_id: string;
|
|
82
|
+
revoked: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface WorkspaceInvitePreviewResponse {
|
|
86
|
+
data: WorkspaceInvitePreview;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface AcceptWorkspaceInviteResponse {
|
|
90
|
+
accepted: boolean;
|
|
91
|
+
workspace_id: string;
|
|
92
|
+
tenant_id: string;
|
|
93
|
+
role: WorkspaceRole;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Main resource ─────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export class WorkspaceInvites {
|
|
99
|
+
constructor(private readonly http: HttpClient) {}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Create a workspace invite or add a member directly.
|
|
103
|
+
*
|
|
104
|
+
* If `email` already maps to a tenant member the user is added directly and
|
|
105
|
+
* `type === "added"` is returned with a `WorkspaceMemberRecord`. Otherwise
|
|
106
|
+
* an invite row is created and `type === "invited"` is returned.
|
|
107
|
+
*
|
|
108
|
+
* `POST /workspaces/:id/invites`
|
|
109
|
+
*/
|
|
110
|
+
async create(
|
|
111
|
+
workspaceId: string,
|
|
112
|
+
params: CreateWorkspaceInviteParams,
|
|
113
|
+
): Promise<CreateWorkspaceInviteResponse> {
|
|
114
|
+
return this.http.post<CreateWorkspaceInviteResponse>(
|
|
115
|
+
`/workspaces/${workspaceId}/invites`,
|
|
116
|
+
params,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* List all pending (non-expired, non-accepted, non-revoked) workspace invites.
|
|
122
|
+
*
|
|
123
|
+
* `GET /workspaces/:id/invites`
|
|
124
|
+
*/
|
|
125
|
+
async list(workspaceId: string): Promise<WorkspaceInvite[]> {
|
|
126
|
+
const res = await this.http.get<WorkspaceInviteListResponse>(
|
|
127
|
+
`/workspaces/${workspaceId}/invites`,
|
|
128
|
+
);
|
|
129
|
+
return res.data ?? [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Revoke a pending workspace invite.
|
|
134
|
+
*
|
|
135
|
+
* Already-revoked invites are idempotent (returns `revoked: true`). An invite
|
|
136
|
+
* that was legitimately accepted throws `409 ALREADY_ACCEPTED`.
|
|
137
|
+
*
|
|
138
|
+
* `DELETE /workspaces/:id/invites/:invite_id`
|
|
139
|
+
*/
|
|
140
|
+
async revoke(
|
|
141
|
+
workspaceId: string,
|
|
142
|
+
inviteId: string,
|
|
143
|
+
): Promise<WorkspaceInviteRevokeResponse> {
|
|
144
|
+
return this.http.delete<WorkspaceInviteRevokeResponse>(
|
|
145
|
+
`/workspaces/${workspaceId}/invites/${inviteId}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Preview a workspace invite by token (no auth required).
|
|
151
|
+
*
|
|
152
|
+
* Use this to render the invite landing page before prompting the user to
|
|
153
|
+
* log in or sign up. Returns `null` when the token is unknown or revoked.
|
|
154
|
+
*
|
|
155
|
+
* `GET /workspace-invites/:token`
|
|
156
|
+
*/
|
|
157
|
+
async preview(token: string): Promise<WorkspaceInvitePreview | null> {
|
|
158
|
+
try {
|
|
159
|
+
const res = await this.http.get<WorkspaceInvitePreviewResponse>(
|
|
160
|
+
`/workspace-invites/${token}`,
|
|
161
|
+
);
|
|
162
|
+
return res.data ?? null;
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Accept a workspace invite on behalf of the authenticated user.
|
|
170
|
+
*
|
|
171
|
+
* The caller's JWT email must match the invite email (case-insensitive).
|
|
172
|
+
*
|
|
173
|
+
* Error codes:
|
|
174
|
+
* - `INVALID_TOKEN` (404) — token not found.
|
|
175
|
+
* - `EXPIRED` (410) — invite TTL elapsed.
|
|
176
|
+
* - `REVOKED` (409) — invite was revoked.
|
|
177
|
+
* - `ALREADY_ACCEPTED` (409) — already used.
|
|
178
|
+
* - `EMAIL_MISMATCH` (422) — JWT email differs from invite email.
|
|
179
|
+
*
|
|
180
|
+
* `POST /workspace-invites/:token/accept`
|
|
181
|
+
*/
|
|
182
|
+
async accept(token: string): Promise<AcceptWorkspaceInviteResponse> {
|
|
183
|
+
return this.http.post<AcceptWorkspaceInviteResponse>(
|
|
184
|
+
`/workspace-invites/${token}/accept`,
|
|
185
|
+
{},
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { HttpClient } from "../http.js";
|
|
3
|
+
import { WorkspaceMembers } from "./workspace-members.js";
|
|
4
|
+
|
|
5
|
+
const mockGet = vi.fn();
|
|
6
|
+
const mockPost = vi.fn();
|
|
7
|
+
const mockPatch = vi.fn();
|
|
8
|
+
const mockDelete = vi.fn();
|
|
9
|
+
const mockRequest = vi.fn();
|
|
10
|
+
|
|
11
|
+
function makeHttp(): HttpClient {
|
|
12
|
+
const http = {} as HttpClient;
|
|
13
|
+
http.get = mockGet;
|
|
14
|
+
http.post = mockPost;
|
|
15
|
+
http.patch = mockPatch;
|
|
16
|
+
http.delete = mockDelete;
|
|
17
|
+
http.request = mockRequest;
|
|
18
|
+
return http;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MEMBER = {
|
|
22
|
+
user_id: "usr_abc",
|
|
23
|
+
email: "alice@example.com",
|
|
24
|
+
name: "Alice",
|
|
25
|
+
avatar_url: null,
|
|
26
|
+
role: "member" as const,
|
|
27
|
+
joined_at: "2026-05-01T10:00:00Z",
|
|
28
|
+
added_by: null,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.clearAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("WorkspaceMembers", () => {
|
|
36
|
+
describe("list()", () => {
|
|
37
|
+
it("calls GET /workspaces/:id/members and returns array", async () => {
|
|
38
|
+
mockGet.mockResolvedValue({ data: [MEMBER] });
|
|
39
|
+
const resource = new WorkspaceMembers(makeHttp());
|
|
40
|
+
|
|
41
|
+
const result = await resource.list("ws-uuid");
|
|
42
|
+
|
|
43
|
+
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-uuid/members");
|
|
44
|
+
expect(result).toHaveLength(1);
|
|
45
|
+
expect(result[0].user_id).toBe("usr_abc");
|
|
46
|
+
expect(result[0].email).toBe("alice@example.com");
|
|
47
|
+
expect(result[0].role).toBe("member");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns empty array when data is empty", async () => {
|
|
51
|
+
mockGet.mockResolvedValue({ data: [] });
|
|
52
|
+
const resource = new WorkspaceMembers(makeHttp());
|
|
53
|
+
|
|
54
|
+
const result = await resource.list("ws-uuid");
|
|
55
|
+
expect(result).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("add()", () => {
|
|
60
|
+
it("calls POST /workspaces/:id/members with userId and role", async () => {
|
|
61
|
+
const record = {
|
|
62
|
+
user_id: "usr_def",
|
|
63
|
+
workspace_id: "ws-uuid",
|
|
64
|
+
role: "member",
|
|
65
|
+
joined_at: "2026-05-22T09:00:00Z",
|
|
66
|
+
added_by: "usr_abc",
|
|
67
|
+
};
|
|
68
|
+
mockPost.mockResolvedValue({ data: record });
|
|
69
|
+
const resource = new WorkspaceMembers(makeHttp());
|
|
70
|
+
|
|
71
|
+
const result = await resource.add("ws-uuid", {
|
|
72
|
+
user_id: "usr_def",
|
|
73
|
+
role: "member",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(mockPost).toHaveBeenCalledWith(
|
|
77
|
+
"/workspaces/ws-uuid/members",
|
|
78
|
+
expect.objectContaining({ user_id: "usr_def", role: "member" }),
|
|
79
|
+
);
|
|
80
|
+
expect(result.user_id).toBe("usr_def");
|
|
81
|
+
expect(result.role).toBe("member");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("updateRole()", () => {
|
|
86
|
+
it("calls PATCH /workspaces/:id/members/:userId with new role", async () => {
|
|
87
|
+
const updated = {
|
|
88
|
+
user_id: "usr_def",
|
|
89
|
+
workspace_id: "ws-uuid",
|
|
90
|
+
role: "admin",
|
|
91
|
+
joined_at: "2026-05-22T09:00:00Z",
|
|
92
|
+
added_by: "usr_abc",
|
|
93
|
+
};
|
|
94
|
+
mockPatch.mockResolvedValue({ data: updated });
|
|
95
|
+
const resource = new WorkspaceMembers(makeHttp());
|
|
96
|
+
|
|
97
|
+
const result = await resource.updateRole("ws-uuid", "usr_def", {
|
|
98
|
+
role: "admin",
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(mockPatch).toHaveBeenCalledWith(
|
|
102
|
+
"/workspaces/ws-uuid/members/usr_def",
|
|
103
|
+
{ role: "admin" },
|
|
104
|
+
);
|
|
105
|
+
expect(result.role).toBe("admin");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("remove()", () => {
|
|
110
|
+
it("calls DELETE /workspaces/:id/members/:userId", async () => {
|
|
111
|
+
mockDelete.mockResolvedValue({ deleted: true });
|
|
112
|
+
const resource = new WorkspaceMembers(makeHttp());
|
|
113
|
+
|
|
114
|
+
await resource.remove("ws-uuid", "usr_def");
|
|
115
|
+
|
|
116
|
+
expect(mockDelete).toHaveBeenCalledWith(
|
|
117
|
+
"/workspaces/ws-uuid/members/usr_def",
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|