@antasphere/chassis-sdk 0.0.0-stage → 1.0.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/src/index.ts ADDED
@@ -0,0 +1,822 @@
1
+ import type {
2
+ ApiKeyCreateOf,
3
+ ApiKeyCreatedOf,
4
+ ApiKeyInfoOf,
5
+ AuditEntry,
6
+ AuditVia,
7
+ BreakGlassClaimOwnership,
8
+ BreakGlassClaimOwnershipRequest,
9
+ BreakGlassResetTwoFactor,
10
+ BreakGlassResetTwoFactorRequest,
11
+ ChassisContract,
12
+ CliAuthComplete,
13
+ CliAuthCompletedOf,
14
+ CliAuthRequest,
15
+ CliAuthRequested,
16
+ CliAuthRevoked,
17
+ DefaultWorkspace,
18
+ DemoPass,
19
+ DemoPassesList,
20
+ DemoPassMint,
21
+ DemoPassMinted,
22
+ DemoPassRedeemed,
23
+ FileInfo,
24
+ InstanceInfo,
25
+ InvitationAccept,
26
+ InvitationCreate,
27
+ InvitationCreated,
28
+ InvitationInfo,
29
+ InvitationLookup,
30
+ MeResponseOf,
31
+ Member,
32
+ MemberChangeEmailLink,
33
+ MemberChangeEmailLinkRequest,
34
+ MemberResetLink,
35
+ MemberUpdate,
36
+ OnboardingDismissed,
37
+ Project,
38
+ ProjectCreate,
39
+ ProjectMember,
40
+ ProjectMemberAdd,
41
+ ProjectMembersList,
42
+ ProjectPersonMember,
43
+ ProjectRole,
44
+ ProjectsArchivedFilter,
45
+ ProjectsList,
46
+ ProjectTeamMember,
47
+ ProjectUpdate,
48
+ SetupRequest,
49
+ SetupResponse,
50
+ SsoCliConnect,
51
+ SsoLogoutResponse,
52
+ Team,
53
+ TeamCreate,
54
+ TeamMemberAdd,
55
+ TeamMemberInfo,
56
+ TeamMembersList,
57
+ TeamsList,
58
+ TeamUpdate,
59
+ WorkspaceCreated,
60
+ WorkspaceLook,
61
+ WorkspaceRole,
62
+ WorkspaceUpdate,
63
+ WorkspaceUpdated
64
+ } from '@antasphere/chassis-contract';
65
+
66
+ /**
67
+ * The scope-carrying wire types, read off the chassis contract a tool instantiates
68
+ * with its own scopes: `ChassisClient<Scope>` types them exactly as the tool's own
69
+ * `ApiKeyInfo`, `MeResponse`… (same `z.infer` over the same `ChassisContract<Scope>`).
70
+ */
71
+ type ApiKeyInfo<TScope extends string> = ApiKeyInfoOf<ChassisContract<TScope>>;
72
+ type ApiKeyCreate<TScope extends string> = ApiKeyCreateOf<ChassisContract<TScope>>;
73
+ type ApiKeyCreated<TScope extends string> = ApiKeyCreatedOf<ChassisContract<TScope>>;
74
+ type CliAuthCompleted<TScope extends string> = CliAuthCompletedOf<ChassisContract<TScope>>;
75
+ type MeResponse<TScope extends string> = MeResponseOf<ChassisContract<TScope>>;
76
+
77
+ /**
78
+ * Thin typed client over /api/v1. Isomorphic: in the dashboard it rides the
79
+ * same-origin session cookie; in Node/CLI contexts pass an API key.
80
+ */
81
+
82
+ export class PlatformApiError extends Error {
83
+ constructor(
84
+ public readonly status: number,
85
+ public readonly code: string,
86
+ message: string,
87
+ public readonly details?: unknown
88
+ ) {
89
+ super(message);
90
+ this.name = 'PlatformApiError';
91
+ }
92
+ }
93
+
94
+ export interface ClientOptions {
95
+ /** Origin of the instance; '' (default) = same-origin (dashboard). */
96
+ baseUrl?: string;
97
+ /** API key (`<prefix>_<keyid>_<secret>`) for machine callers. */
98
+ apiKey?: string;
99
+ /**
100
+ * Active workspace for EVERY credential kind (sent as X-Workspace-Id —
101
+ * the user-scoped credential model): a caller in several workspaces names
102
+ * which one their requests target; absent, the user's default membership
103
+ * applies. Meaningful with the session cookie AND with user-scoped API
104
+ * keys/OAuth tokens; a PINNED key rejects a mismatching value server-side
105
+ * (403 workspace_mismatch). Mutable later via setWorkspace().
106
+ */
107
+ workspaceId?: string;
108
+ fetch?: typeof globalThis.fetch;
109
+ /**
110
+ * Deadline for the JSON API calls, in milliseconds (default 30 000; 0
111
+ * disables). Without one, a hung or throttling instance parks a CLI
112
+ * invocation — or a dashboard request — forever: `fetch` has no default
113
+ * timeout in Node or the browser.
114
+ */
115
+ timeoutMs?: number;
116
+ /**
117
+ * Deadline for the byte-streaming calls — a tool's assets, file content, the
118
+ * workspace export (default 600 000; 0 disables). Separate because it
119
+ * covers the whole body transfer, and an export is legitimately slow.
120
+ */
121
+ downloadTimeoutMs?: number;
122
+ /**
123
+ * Headers sent on EVERY call, merged in before the credential and the
124
+ * workspace are set (so they can never replace either). For a Node caller
125
+ * that holds a browser-style session: the CLI's owner-only commands send
126
+ * the session `cookie` and the `origin` the server checks, and no key.
127
+ */
128
+ headers?: Record<string, string>;
129
+ }
130
+
131
+ /** Cursor-pagination params shared by every list endpoint. */
132
+ export interface ListParams {
133
+ /** `nextCursor` from the previous page; omit for page 1. */
134
+ cursor?: string;
135
+ /** Page size (server default 50, max 100). */
136
+ limit?: number;
137
+ }
138
+
139
+ export interface AuditListResponse {
140
+ entries: AuditEntry[];
141
+ nextCursor: string | null;
142
+ /** How many entries match in all; counted on the first page only, null after it. */
143
+ total: number | null;
144
+ }
145
+
146
+ /** Cursor pagination + the audit log's filters (every one optional, combined with AND). */
147
+ export interface AuditListParams extends ListParams {
148
+ /** Free text, matched case-insensitively against the actor's email and the action. */
149
+ q?: string;
150
+ /** Actions or families: an item ending in `.` matches the family (`apikey.`). */
151
+ action?: string[];
152
+ /** How the actor authenticated. */
153
+ actorVia?: AuditVia[];
154
+ /** One user id, or `system` for the rows nobody signed. */
155
+ actor?: string;
156
+ resourceType?: string;
157
+ resourceId?: string;
158
+ /** ISO datetime: entries created at or after this instant. */
159
+ from?: string;
160
+ /** ISO datetime: entries created at or before this instant. */
161
+ to?: string;
162
+ }
163
+
164
+ export interface InvitationAccepted {
165
+ workspaceId: string;
166
+ userId: string;
167
+ role: WorkspaceRole;
168
+ }
169
+
170
+ export interface FileUploaded {
171
+ file: FileInfo;
172
+ deduplicated: boolean;
173
+ }
174
+
175
+ /**
176
+ * Options for the non-idempotent create calls. Setting `idempotencyKey`
177
+ * (any client-chosen string ≤200 chars, e.g. a UUID) makes a retried create
178
+ * replay the original response instead of double-creating (docs/security/security.md).
179
+ */
180
+ export interface IdempotentRequestOptions {
181
+ idempotencyKey?: string;
182
+ }
183
+
184
+ export function idempotencyHeader(opts: IdempotentRequestOptions): Record<string, string> | undefined {
185
+ return opts.idempotencyKey ? { 'idempotency-key': opts.idempotencyKey } : undefined;
186
+ }
187
+
188
+ /** Default deadline for the JSON API calls (ms). */
189
+ export const DEFAULT_TIMEOUT_MS = 30_000;
190
+ /** Default deadline for the streaming download calls (ms). */
191
+ export const DEFAULT_DOWNLOAD_TIMEOUT_MS = 600_000;
192
+
193
+ export class ChassisClient<TScope extends string> {
194
+ protected readonly baseUrl: string;
195
+ private readonly apiKey: string | undefined;
196
+ private workspaceId: string | undefined;
197
+ protected readonly fetchImpl: typeof globalThis.fetch;
198
+ private readonly timeoutMs: number;
199
+ private readonly downloadTimeoutMs: number;
200
+ private readonly extraHeaders: Record<string, string>;
201
+
202
+ constructor(options: ClientOptions = {}) {
203
+ this.baseUrl = options.baseUrl?.replace(/\/$/, '') ?? '';
204
+ this.apiKey = options.apiKey;
205
+ this.workspaceId = options.workspaceId;
206
+ this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
207
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
208
+ this.downloadTimeoutMs = options.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS;
209
+ this.extraHeaders = { ...options.headers };
210
+ }
211
+
212
+ /**
213
+ * The abort signal every call carries. `fetch` never times out on its
214
+ * own, so a silent peer (or a hostile one holding the socket open) would
215
+ * otherwise hang the caller indefinitely.
216
+ */
217
+ protected signal(kind: 'api' | 'download'): AbortSignal | undefined {
218
+ const ms = kind === 'download' ? this.downloadTimeoutMs : this.timeoutMs;
219
+ return ms > 0 ? AbortSignal.timeout(ms) : undefined;
220
+ }
221
+
222
+ /** Set (or clear) the active workspace all subsequent requests target. */
223
+ setWorkspace(workspaceId: string | null): void {
224
+ this.workspaceId = workspaceId ?? undefined;
225
+ }
226
+
227
+ /** Base headers shared by every call: the caller's own, then credential + active workspace. */
228
+ protected baseHeaders(): Record<string, string> {
229
+ const headers: Record<string, string> = { ...this.extraHeaders };
230
+ if (this.apiKey) headers['authorization'] = `Bearer ${this.apiKey}`;
231
+ if (this.workspaceId) headers['x-workspace-id'] = this.workspaceId;
232
+ return headers;
233
+ }
234
+
235
+ protected async parse<T>(res: Response): Promise<T> {
236
+ if (res.status === 204) return undefined as T;
237
+
238
+ const json = (await res.json().catch(() => null)) as
239
+ { error?: { code?: string; message?: string; details?: unknown } } | T | null;
240
+
241
+ if (!res.ok) {
242
+ const errBody = (json ?? {}) as { error?: { code?: string; message?: string; details?: unknown } };
243
+ throw new PlatformApiError(
244
+ res.status,
245
+ errBody.error?.code ?? 'unknown_error',
246
+ errBody.error?.message ?? `Request failed with ${res.status}`,
247
+ errBody.error?.details
248
+ );
249
+ }
250
+ return json as T;
251
+ }
252
+
253
+ /** Append cursor-pagination params to a list path. */
254
+ protected pathWithQuery(
255
+ base: string,
256
+ params: ListParams,
257
+ extra: Record<string, string | undefined> = {}
258
+ ): string {
259
+ const query = new URLSearchParams();
260
+ if (params.cursor) query.set('cursor', params.cursor);
261
+ if (params.limit !== undefined) query.set('limit', String(params.limit));
262
+ for (const [key, value] of Object.entries(extra)) if (value !== undefined) query.set(key, value);
263
+ const qs = query.toString();
264
+ return qs ? `${base}?${qs}` : base;
265
+ }
266
+
267
+ protected async request<T>(
268
+ method: string,
269
+ path: string,
270
+ body?: unknown,
271
+ extraHeaders?: Record<string, string>
272
+ ): Promise<T> {
273
+ const headers: Record<string, string> = { ...this.baseHeaders(), ...extraHeaders };
274
+ if (body !== undefined) headers['content-type'] = 'application/json';
275
+
276
+ const res = await this.fetchImpl(`${this.baseUrl}/api/v1${path}`, {
277
+ method,
278
+ headers,
279
+ credentials: 'same-origin',
280
+ signal: this.signal('api'),
281
+ // Always see live state: /instance flips setupRequired the moment the
282
+ // wizard completes but is served with public max-age for CLI/MCP
283
+ // discovery — the browser HTTP cache must not answer for the app.
284
+ // (`cache` is a browser-only field; cast keeps this isomorphic under a
285
+ // Node lib where RequestInit omits it.)
286
+ cache: 'no-store',
287
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {})
288
+ } as RequestInit);
289
+ return this.parse<T>(res);
290
+ }
291
+
292
+ instance(): Promise<InstanceInfo> {
293
+ return this.request('GET', '/instance');
294
+ }
295
+
296
+ setup(req: SetupRequest): Promise<SetupResponse> {
297
+ return this.request('POST', '/setup', req);
298
+ }
299
+
300
+ me(): Promise<MeResponse<TScope>> {
301
+ return this.request('GET', '/me');
302
+ }
303
+
304
+ /**
305
+ * CLOUD EDITION only (404 on oss); sessions only (machines 403): record
306
+ * the explicit first-run-welcome dismissal — flips /me's
307
+ * `firstRunPending` to false, permanently. Idempotent.
308
+ */
309
+ dismissOnboarding(): Promise<OnboardingDismissed> {
310
+ return this.request('POST', '/me/onboarding/dismiss');
311
+ }
312
+
313
+ /**
314
+ * Choose the workspace a request naming none resolves to, or clear the
315
+ * choice with `null` (the oldest active membership answers again). The
316
+ * person's own setting, open to their keys under the write scope; a key
317
+ * pinned to one workspace answers 403 `key_pinned`. On the cloud edition
318
+ * the default is set on the account site: 403 `hub_managed` with
319
+ * `details.manageUrl`. 404 for a workspace the caller is no active member of.
320
+ */
321
+ setDefaultWorkspace(workspaceId: string | null): Promise<DefaultWorkspace> {
322
+ return this.request('PUT', '/me/default-workspace', { workspaceId });
323
+ }
324
+
325
+ /**
326
+ * Create ANOTHER workspace with the caller as its owner. Sessions only
327
+ * (API keys and OAuth bearers answer 403); offer it only while `/me`'s
328
+ * `canCreateWorkspace` is true. Self-hosted creates it locally under the
329
+ * operator's per-person cap; cloud creates the organization at Antasphere
330
+ * as the caller. Either way `workspace.id` is the id to pass as
331
+ * `X-Workspace-Id` (`setWorkspace`). Refusals (403 unless noted):
332
+ * `session_required`, `guest_forbidden`, `workspace_creation_disabled`,
333
+ * `workspace_limit_reached`, `hub_link_required`, `hub_unavailable`,
334
+ * `hub_refused`, 401 `hub_grant_expired`, 401 `hub_reauth_required` (both
335
+ * healed by signing in again), 400 `validation_error`, 429 `rate_limited`.
336
+ */
337
+ createWorkspace(
338
+ name: string,
339
+ opts: IdempotentRequestOptions & { look?: Partial<WorkspaceLook> } = {}
340
+ ): Promise<WorkspaceCreated> {
341
+ const { look, ...rest } = opts;
342
+ return this.request('POST', '/workspaces', look ? { name, look } : { name }, idempotencyHeader(rest));
343
+ }
344
+
345
+ /**
346
+ * PATCH /workspace — the ACTIVE workspace's name and look (owner/admin,
347
+ * sessions only). A rename of a hub-origin workspace answers 403
348
+ * `hub_managed` with `details.manageUrl`; its look is accepted.
349
+ */
350
+ updateWorkspace(body: WorkspaceUpdate): Promise<WorkspaceUpdated> {
351
+ return this.request('PATCH', '/workspace', body);
352
+ }
353
+
354
+ /** Better Auth session probe — null when signed out. */
355
+ async session(): Promise<{ user: { id: string; email: string; name: string } } | null> {
356
+ return this.request('GET', '/auth/get-session');
357
+ }
358
+
359
+ signInEmail(email: string, password: string): Promise<{ user: { id: string } }> {
360
+ return this.request('POST', '/auth/sign-in/email', { email, password });
361
+ }
362
+
363
+ signOut(): Promise<void> {
364
+ return this.request('POST', '/auth/sign-out', {});
365
+ }
366
+
367
+ // ── CLI auth (browserless email-OTP → API key) ────────────────────────────
368
+
369
+ /** Public: email a sign-in code. Generic success — silent about account existence. */
370
+ cliAuthRequest(req: CliAuthRequest): Promise<CliAuthRequested> {
371
+ return this.request('POST', '/cli/auth/request', req);
372
+ }
373
+
374
+ /**
375
+ * Public: verify the code and mint an API key (the tool's read + write
376
+ * scopes). The returned `key` appears only here.
377
+ */
378
+ cliAuthComplete(req: CliAuthComplete): Promise<CliAuthCompleted<TScope>> {
379
+ return this.request('POST', '/cli/auth/complete', req);
380
+ }
381
+
382
+ /**
383
+ * Revoke the PRESENTING API key (CLI logout). Self-revocation only: the
384
+ * route names no key, so the credential can kill exactly itself. Requires
385
+ * an API-key credential with the tool's write scope (sessions and OAuth
386
+ * bearers are refused — the dashboard is their key-management surface).
387
+ */
388
+ cliAuthRevoke(): Promise<CliAuthRevoked> {
389
+ return this.request('DELETE', '/cli/auth/key');
390
+ }
391
+
392
+ /**
393
+ * Public, CLOUD EDITION only (404 on oss): exchange a hub-minted 120 s
394
+ * connect JWT (the hub's /sso/tool-token response) for an API key
395
+ * bound to the projected workspace — same grant and one-shot response
396
+ * shape as cliAuthComplete. Each token works exactly once (jti).
397
+ */
398
+ ssoCliConnect(req: SsoCliConnect): Promise<CliAuthCompleted<TScope>> {
399
+ return this.request('POST', '/sso/cli-connect', req);
400
+ }
401
+
402
+ /**
403
+ * CLOUD EDITION only (404 on oss); sessions only (machines 403 — the
404
+ * path is unlisted in the scope allowlist): single logout. The server
405
+ * revokes the local session and clears the SSO hint cookie in this very
406
+ * response; `url` is the hub end-session URL the browser must then VISIT
407
+ * to end the hub anchor session (null = local signout only — go straight
408
+ * to /login?signed_out=1).
409
+ */
410
+ ssoLogout(): Promise<SsoLogoutResponse> {
411
+ return this.request('POST', '/sso/logout');
412
+ }
413
+
414
+ // ── Members ───────────────────────────────────────────────────────────────
415
+
416
+ members(params: ListParams = {}): Promise<{ members: Member[]; nextCursor: string | null }> {
417
+ return this.request('GET', this.pathWithQuery('/members', params));
418
+ }
419
+
420
+ updateMember(id: string, patch: MemberUpdate): Promise<Member> {
421
+ return this.request('PATCH', `/members/${encodeURIComponent(id)}`, patch);
422
+ }
423
+
424
+ /**
425
+ * Admin: delete a member's account (GDPR erasure). Sessions only — the
426
+ * endpoint is unlisted in the machine scope allowlist, so API keys 403.
427
+ * Files the member uploaded stay with the workspace.
428
+ */
429
+ deleteMember(id: string): Promise<Member> {
430
+ return this.request('DELETE', `/members/${encodeURIComponent(id)}`);
431
+ }
432
+
433
+ /**
434
+ * Remove a member from the workspace (admin+, sessions only): the
435
+ * membership is switched off and their project grants and team seats are
436
+ * deleted; the account stays. Invited back, the person starts with none.
437
+ * Refused `hub_managed` on a hub-origin workspace (removal is the hub's).
438
+ */
439
+ removeMember(id: string): Promise<Member> {
440
+ return this.request('POST', `/members/${encodeURIComponent(id)}/remove`);
441
+ }
442
+
443
+ /**
444
+ * Owner: mint a one-time password reset link for a member (SMTP-free
445
+ * recovery). Refused for a per-resource guest and for anyone who also belongs
446
+ * to another workspace — the minted credential is global (PRDCT-1354).
447
+ */
448
+ createMemberResetLink(id: string, opts: IdempotentRequestOptions = {}): Promise<MemberResetLink> {
449
+ return this.request(
450
+ 'POST',
451
+ `/members/${encodeURIComponent(id)}/reset-link`,
452
+ undefined,
453
+ idempotencyHeader(opts)
454
+ );
455
+ }
456
+
457
+ /**
458
+ * Owner: mint a one-time email change link for a member (SMTP-free email
459
+ * change). ⚠️ The link updates the email AND signs the member in — share
460
+ * it with the target member only. Same refusals as the reset link (guest
461
+ * targets, cross-workspace targets), plus the cloud edition closure.
462
+ */
463
+ createMemberChangeEmailLink(
464
+ id: string,
465
+ req: MemberChangeEmailLinkRequest,
466
+ opts: IdempotentRequestOptions = {}
467
+ ): Promise<MemberChangeEmailLink> {
468
+ return this.request(
469
+ 'POST',
470
+ `/members/${encodeURIComponent(id)}/change-email-link`,
471
+ req,
472
+ idempotencyHeader(opts)
473
+ );
474
+ }
475
+
476
+ // ── Projects ──────────────────────────────────────────────────────────────
477
+ // A project is a subgroup of the workspace with its own members and three
478
+ // roles (viewer, editor, manager). A project the caller cannot read answers
479
+ // 404; an archived one answers 409 `project_archived` to every change but
480
+ // `unarchiveProject`. A project is never deleted.
481
+
482
+ /** The caller's projects (every project for a workspace owner or admin). `archived`: `false` by default. */
483
+ projects(params: ListParams & { archived?: ProjectsArchivedFilter } = {}): Promise<ProjectsList> {
484
+ return this.request('GET', this.pathWithQuery('/projects', params, { archived: params.archived }));
485
+ }
486
+
487
+ /** The caller becomes the project's first manager. */
488
+ createProject(req: ProjectCreate, opts: IdempotentRequestOptions = {}): Promise<Project> {
489
+ return this.request('POST', '/projects', req, idempotencyHeader(opts));
490
+ }
491
+
492
+ project(id: string): Promise<Project> {
493
+ return this.request('GET', `/projects/${encodeURIComponent(id)}`);
494
+ }
495
+
496
+ updateProject(id: string, patch: ProjectUpdate): Promise<Project> {
497
+ return this.request('PATCH', `/projects/${encodeURIComponent(id)}`, patch);
498
+ }
499
+
500
+ archiveProject(id: string): Promise<Project> {
501
+ return this.request('POST', `/projects/${encodeURIComponent(id)}/archive`);
502
+ }
503
+
504
+ unarchiveProject(id: string): Promise<Project> {
505
+ return this.request('POST', `/projects/${encodeURIComponent(id)}/unarchive`);
506
+ }
507
+
508
+ projectMembers(id: string, params: ListParams = {}): Promise<ProjectMembersList> {
509
+ return this.request('GET', this.pathWithQuery(`/projects/${encodeURIComponent(id)}/members`, params));
510
+ }
511
+
512
+ /**
513
+ * Adds one of the workspace's own active members, by user id or by email,
514
+ * or one of its teams by `teamId`: exactly one of the three. A team entry
515
+ * gives every member of the team the role.
516
+ */
517
+ addProjectMember(
518
+ id: string,
519
+ req: ProjectMemberAdd,
520
+ opts: IdempotentRequestOptions = {}
521
+ ): Promise<ProjectMember> {
522
+ return this.request('POST', `/projects/${encodeURIComponent(id)}/members`, req, idempotencyHeader(opts));
523
+ }
524
+
525
+ setProjectMemberRole(id: string, userId: string, role: ProjectRole): Promise<ProjectPersonMember> {
526
+ return this.request(
527
+ 'PATCH',
528
+ `/projects/${encodeURIComponent(id)}/members/${encodeURIComponent(userId)}`,
529
+ { role }
530
+ );
531
+ }
532
+
533
+ /** A manager removes anyone; any member removes themselves. */
534
+ removeProjectMember(id: string, userId: string): Promise<ProjectPersonMember> {
535
+ return this.request(
536
+ 'DELETE',
537
+ `/projects/${encodeURIComponent(id)}/members/${encodeURIComponent(userId)}`
538
+ );
539
+ }
540
+
541
+ /** Changes the role a team holds on a project (manager). */
542
+ setProjectTeamRole(projectId: string, teamId: string, role: ProjectRole): Promise<ProjectTeamMember> {
543
+ return this.request(
544
+ 'PATCH',
545
+ `/projects/${encodeURIComponent(projectId)}/teams/${encodeURIComponent(teamId)}`,
546
+ { role }
547
+ );
548
+ }
549
+
550
+ /** Removes a team from a project (manager); its people keep their own entries. */
551
+ removeProjectTeam(projectId: string, teamId: string): Promise<ProjectTeamMember> {
552
+ return this.request(
553
+ 'DELETE',
554
+ `/projects/${encodeURIComponent(projectId)}/teams/${encodeURIComponent(teamId)}`
555
+ );
556
+ }
557
+
558
+ // ── Teams ─────────────────────────────────────────────────────────────────
559
+ // A team is a named group of the workspace's own members, on both editions.
560
+ // Every non-guest member reads them; owners and admins shape them. In a
561
+ // workspace managed by the account site every write answers 403
562
+ // `hub_managed` with `details.manageUrl`.
563
+
564
+ /** The workspace's teams, newest first. */
565
+ teams(params: ListParams = {}): Promise<TeamsList> {
566
+ return this.request('GET', this.pathWithQuery('/teams', params));
567
+ }
568
+
569
+ team(id: string): Promise<Team> {
570
+ return this.request('GET', `/teams/${encodeURIComponent(id)}`);
571
+ }
572
+
573
+ /** Owner or admin. The slug is derived from the name when absent. */
574
+ createTeam(req: TeamCreate, opts: IdempotentRequestOptions = {}): Promise<Team> {
575
+ return this.request('POST', '/teams', req, idempotencyHeader(opts));
576
+ }
577
+
578
+ updateTeam(id: string, patch: TeamUpdate): Promise<Team> {
579
+ return this.request('PATCH', `/teams/${encodeURIComponent(id)}`, patch);
580
+ }
581
+
582
+ /** The memberships stay; answers the team's final snapshot. */
583
+ deleteTeam(id: string): Promise<Team> {
584
+ return this.request('DELETE', `/teams/${encodeURIComponent(id)}`);
585
+ }
586
+
587
+ teamMembers(id: string, params: ListParams = {}): Promise<TeamMembersList> {
588
+ return this.request('GET', this.pathWithQuery(`/teams/${encodeURIComponent(id)}/members`, params));
589
+ }
590
+
591
+ /** Seats one of the workspace's own active members, by user id or by email. */
592
+ addTeamMember(
593
+ id: string,
594
+ req: TeamMemberAdd,
595
+ opts: IdempotentRequestOptions = {}
596
+ ): Promise<TeamMemberInfo> {
597
+ return this.request('POST', `/teams/${encodeURIComponent(id)}/members`, req, idempotencyHeader(opts));
598
+ }
599
+
600
+ /** Unseats a member; the workspace membership stays. */
601
+ removeTeamMember(id: string, userId: string): Promise<TeamMemberInfo> {
602
+ return this.request('DELETE', `/teams/${encodeURIComponent(id)}/members/${encodeURIComponent(userId)}`);
603
+ }
604
+
605
+ // ── Demo passes ───────────────────────────────────────────────────────────
606
+ // A link that signs one member in without a password, for a demonstration.
607
+ // The routes exist only while `instance().demoSignIn` is true (a self-hosted
608
+ // instance whose operator turned DEMO_SIGN_IN on); otherwise they answer
609
+ // 404 like any unknown path. Owner and session only: an admin or a member
610
+ // gets 403 `forbidden`, an API key 403 before the route.
611
+
612
+ /** Owner: the workspace's passes, newest first. Never carries a secret. */
613
+ demoPasses(): Promise<DemoPassesList> {
614
+ return this.request('GET', '/demo/passes');
615
+ }
616
+
617
+ /**
618
+ * Owner: mint a pass for a member by address. The `secret` and the `url`
619
+ * (`<instance>/demo#pass=…&to=…`) appear in this answer only. Refusals, in
620
+ * this order: 404 `no_such_member`, 403 `owner_target`,
621
+ * `demo_address_required`, `two_factor_enrolled`, `guest_target`,
622
+ * `cross_workspace_target`, then 400 `invalid_demo_path` or
623
+ * `validation_error` (a lifetime outside 1 to 10080 minutes).
624
+ */
625
+ mintDemoPass(req: DemoPassMint): Promise<DemoPassMinted> {
626
+ return this.request('POST', '/demo/passes', req);
627
+ }
628
+
629
+ /** Owner: revoke a pass; revoking a revoked pass answers it again. */
630
+ revokeDemoPass(id: string): Promise<DemoPass> {
631
+ return this.request('DELETE', `/demo/passes/${encodeURIComponent(id)}`);
632
+ }
633
+
634
+ /**
635
+ * Anyone holding a pass's secret: sign in as its person. Served by the
636
+ * sign-in library (like `signInEmail`, no route contract), which sets the
637
+ * session cookie on this very answer; in a browser, call it same-origin.
638
+ * Every refusal of a pass is one 401 `invalid_demo_pass`, whatever the
639
+ * reason; 429 `rate_limited` past 20 REFUSED tries in 15 minutes from one address.
640
+ */
641
+ redeemDemoPass(secret: string): Promise<DemoPassRedeemed> {
642
+ return this.request('POST', '/auth/demo/redeem', { pass: secret });
643
+ }
644
+
645
+ // ── Break-glass (superadmin recovery, ADR 010) ────────────────────────────
646
+
647
+ /**
648
+ * Superadmin: make the caller (or a named existing user) an ACTIVE OWNER
649
+ * of the workspace — creating, reactivating, or promoting the membership.
650
+ * Adds an owner, never removes one. Sessions only: the endpoint is
651
+ * unlisted in the machine scope allowlist (API keys/tokens 403), and the
652
+ * calling session's VERIFIED email must be on SUPERADMIN_EMAILS.
653
+ */
654
+ breakGlassClaimOwnership(req: BreakGlassClaimOwnershipRequest = {}): Promise<BreakGlassClaimOwnership> {
655
+ return this.request('POST', '/admin/break-glass/claim-ownership', req);
656
+ }
657
+
658
+ /** Superadmin: clear a locked-out user's 2FA (sessions only, audited). */
659
+ breakGlassResetTwoFactor(req: BreakGlassResetTwoFactorRequest): Promise<BreakGlassResetTwoFactor> {
660
+ return this.request('POST', '/admin/break-glass/reset-2fa', req);
661
+ }
662
+
663
+ // ── API keys ──────────────────────────────────────────────────────────────
664
+
665
+ apiKeys(params: ListParams = {}): Promise<{ apiKeys: ApiKeyInfo<TScope>[]; nextCursor: string | null }> {
666
+ return this.request('GET', this.pathWithQuery('/api-keys', params));
667
+ }
668
+
669
+ /** The returned `key` is the full secret — shown once, never retrievable. */
670
+ createApiKey(
671
+ req: ApiKeyCreate<TScope>,
672
+ opts: IdempotentRequestOptions = {}
673
+ ): Promise<ApiKeyCreated<TScope>> {
674
+ return this.request('POST', '/api-keys', req, idempotencyHeader(opts));
675
+ }
676
+
677
+ revokeApiKey(id: string): Promise<ApiKeyInfo<TScope>> {
678
+ return this.request('DELETE', `/api-keys/${encodeURIComponent(id)}`);
679
+ }
680
+
681
+ // ── Invitations ───────────────────────────────────────────────────────────
682
+
683
+ invitations(
684
+ params: ListParams = {}
685
+ ): Promise<{ invitations: InvitationInfo[]; nextCursor: string | null }> {
686
+ return this.request('GET', this.pathWithQuery('/invitations', params));
687
+ }
688
+
689
+ /** `acceptUrl` is always returned — email delivery is best-effort on top. */
690
+ createInvitation(req: InvitationCreate, opts: IdempotentRequestOptions = {}): Promise<InvitationCreated> {
691
+ return this.request('POST', '/invitations', req, idempotencyHeader(opts));
692
+ }
693
+
694
+ revokeInvitation(id: string): Promise<InvitationInfo> {
695
+ return this.request('DELETE', `/invitations/${encodeURIComponent(id)}`);
696
+ }
697
+
698
+ /** Public: resolves an invitation token for the acceptance page. */
699
+ lookupInvitation(token: string): Promise<InvitationLookup> {
700
+ return this.request('GET', `/invitations/lookup?token=${encodeURIComponent(token)}`);
701
+ }
702
+
703
+ /** Public: accepts an invitation (creates the account when needed). */
704
+ acceptInvitation(req: InvitationAccept): Promise<InvitationAccepted> {
705
+ return this.request('POST', '/invitations/accept', req);
706
+ }
707
+
708
+ // ── Audit ─────────────────────────────────────────────────────────────────
709
+
710
+ audit(params: AuditListParams = {}): Promise<AuditListResponse> {
711
+ const query = new URLSearchParams();
712
+ if (params.cursor) query.set('cursor', params.cursor);
713
+ if (params.limit !== undefined) query.set('limit', String(params.limit));
714
+ if (params.q) query.set('q', params.q);
715
+ if (params.action?.length) query.set('action', params.action.join(','));
716
+ if (params.actorVia?.length) query.set('actorVia', params.actorVia.join(','));
717
+ if (params.actor) query.set('actor', params.actor);
718
+ if (params.resourceType) query.set('resourceType', params.resourceType);
719
+ if (params.resourceId) query.set('resourceId', params.resourceId);
720
+ if (params.from) query.set('from', params.from);
721
+ if (params.to) query.set('to', params.to);
722
+ const qs = query.toString();
723
+ return this.request('GET', qs ? `/audit?${qs}` : '/audit');
724
+ }
725
+
726
+ // ── Files ─────────────────────────────────────────────────────────────────
727
+
728
+ files(params: ListParams = {}): Promise<{ files: FileInfo[]; nextCursor: string | null }> {
729
+ return this.request('GET', this.pathWithQuery('/files', params));
730
+ }
731
+
732
+ /** Uploads raw bytes; the filename travels as a query param. */
733
+ async uploadFile(
734
+ name: string,
735
+ body: Blob | ArrayBuffer | Uint8Array,
736
+ contentType?: string
737
+ ): Promise<FileUploaded> {
738
+ const type =
739
+ contentType ??
740
+ (typeof Blob !== 'undefined' && body instanceof Blob && body.type
741
+ ? body.type
742
+ : 'application/octet-stream');
743
+ const headers: Record<string, string> = { ...this.baseHeaders(), 'content-type': type };
744
+
745
+ const res = await this.fetchImpl(`${this.baseUrl}/api/v1/files?name=${encodeURIComponent(name)}`, {
746
+ method: 'POST',
747
+ headers,
748
+ credentials: 'same-origin',
749
+ signal: this.signal('download'),
750
+ body: body as unknown as RequestInit['body']
751
+ } as RequestInit);
752
+ return this.parse<FileUploaded>(res);
753
+ }
754
+
755
+ deleteFile(id: string): Promise<FileInfo> {
756
+ return this.request('DELETE', `/files/${encodeURIComponent(id)}`);
757
+ }
758
+
759
+ file(id: string): Promise<FileInfo> {
760
+ return this.request('GET', `/files/${encodeURIComponent(id)}`);
761
+ }
762
+
763
+ /** URL of the streamed (Range-capable) content endpoint for a file. */
764
+ fileContentUrl(id: string): string {
765
+ return `${this.baseUrl}/api/v1/files/${encodeURIComponent(id)}/content`;
766
+ }
767
+
768
+ /**
769
+ * Downloads a file's bytes with the client's own headers (credential and
770
+ * active workspace). Raw Response so callers can stream it; a non-2xx
771
+ * answer throws PlatformApiError like every other method. A browser anchor
772
+ * on `fileContentUrl` cannot carry `X-Workspace-Id` (PRDCT-2426).
773
+ */
774
+ async downloadFileContent(id: string): Promise<Response> {
775
+ return this.rawDownload(this.fileContentUrl(id));
776
+ }
777
+
778
+ /** The shared body of the streamed-download methods (the asset download's shape). */
779
+ protected async rawDownload(url: string): Promise<Response> {
780
+ const headers: Record<string, string> = this.baseHeaders();
781
+ const res = await this.fetchImpl(url, {
782
+ method: 'GET',
783
+ headers,
784
+ credentials: 'same-origin',
785
+ signal: this.signal('download'),
786
+ // Browser-only field; cast keeps this isomorphic under a Node lib.
787
+ cache: 'no-store'
788
+ } as RequestInit);
789
+ if (!res.ok) {
790
+ await this.parse(res); // throws PlatformApiError with the wire shape
791
+ }
792
+ return res;
793
+ }
794
+
795
+ // ── Workspace export ──────────────────────────────────────────────────────
796
+
797
+ /** URL of the workspace export endpoint (admin+; keys need the tool's export scope). */
798
+ workspaceExportUrl(): string {
799
+ return `${this.baseUrl}/api/v1/workspace/export`;
800
+ }
801
+
802
+ /**
803
+ * Downloads the full workspace export. Returns the raw Response so callers
804
+ * can stream the zip (exports can be large — never buffer by default);
805
+ * a non-2xx answer throws PlatformApiError like every other method.
806
+ */
807
+ async downloadExport(): Promise<Response> {
808
+ const headers: Record<string, string> = this.baseHeaders();
809
+ const res = await this.fetchImpl(this.workspaceExportUrl(), {
810
+ method: 'GET',
811
+ headers,
812
+ credentials: 'same-origin',
813
+ signal: this.signal('download'),
814
+ // Browser-only field; cast keeps this isomorphic under a Node lib.
815
+ cache: 'no-store'
816
+ } as RequestInit);
817
+ if (!res.ok) {
818
+ await this.parse(res); // throws PlatformApiError with the wire shape
819
+ }
820
+ return res;
821
+ }
822
+ }