/** * Typed client for the LogicSRC team credential-sharing API (commandboard-api, * routes under /api/credshare). Used by both the `team` credential provider and * the CLI `teams`/`login` commands. * * All secret material sent through this client is already ciphertext (or a DEK * sealed to a member public key). The server is zero-knowledge for values. */ export interface TeamClientOptions { apiUrl: string; token?: string; } export interface RemoteUser { id: string; email: string; publicKey: string | null; } export interface RemoteTeam { id: string; slug: string; name: string; } export interface RemoteMember { id: string; email: string; role: "owner" | "admin" | "member"; status: "active" | "invited"; hasPublicKey: boolean; joinedAt: string | null; } export interface RemoteVault { id: string; name: string; hasAccess: boolean; secretCount: number; } export interface RemoteSecret { name: string; nonce: string; ciphertext: string; fingerprint: string; version: number; updatedAt: string; } export interface RemoteGrantRow { email: string; /** X25519 public key, so a client can re-seal to every member in one pass. */ publicKey: string | null; status: "active" | "invited"; hasPublicKey: boolean; hasAccess: boolean; } export interface RekeyResult { ok: boolean; rekeyed: number; granted: string[]; revoked: string[]; } export class TeamApiError extends Error { constructor( public readonly status: number, message: string ) { super(message); this.name = "TeamApiError"; } } export class TeamClient { private readonly apiUrl: string; private token?: string; constructor(options: TeamClientOptions) { this.apiUrl = options.apiUrl.replace(/\/$/, ""); this.token = options.token; } setToken(token: string): void { this.token = token; } private async request(method: string, path: string, body?: unknown): Promise { const headers: Record = { accept: "application/json" }; if (body !== undefined) headers["content-type"] = "application/json"; if (this.token) headers["authorization"] = `Bearer ${this.token}`; const response = await fetch(`${this.apiUrl}/api/credshare${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); const text = await response.text(); const parsed = text ? (JSON.parse(text) as unknown) : undefined; if (!response.ok) { const message = isRecord(parsed) && typeof parsed.error === "string" ? parsed.error : `${response.status} ${response.statusText}`; throw new TeamApiError(response.status, message); } return parsed as T; } // ---- auth ---- requestLoginCode(email: string) { return this.request<{ ok: boolean; emailSent: boolean; devCode?: string }>("POST", "/auth/request", { email }); } verifyLoginCode(email: string, code: string) { return this.request<{ token: string; user: RemoteUser }>("POST", "/auth/verify", { email, code }); } uploadPublicKey(publicKey: string) { return this.request<{ email: string; publicKey: string }>("POST", "/keys", { publicKey }); } me() { return this.request<{ user: RemoteUser; teams: RemoteTeam[] }>("GET", "/me"); } logout() { return this.request<{ ok: boolean }>("POST", "/logout"); } lookupUser(email: string) { return this.request<{ email: string; userId: string | null; publicKey: string | null }>("GET", `/users?email=${encodeURIComponent(email)}`); } // ---- teams / members / invites ---- createTeam(slug: string, name?: string) { return this.request<{ team: RemoteTeam }>("POST", "/teams", { slug, name }); } listTeams() { return this.request<{ teams: RemoteTeam[] }>("GET", "/teams"); } listMembers(slug: string) { return this.request<{ members: RemoteMember[] }>("GET", `/teams/${encodeURIComponent(slug)}/members`); } invite(slug: string, email: string, role?: "owner" | "admin" | "member") { return this.request<{ invite: { id: string; email: string; role: string; expiresAt: number }; emailSent: boolean; resent: boolean; token?: string }>( "POST", `/teams/${encodeURIComponent(slug)}/invites`, { email, role } ); } updateMemberRole(slug: string, memberId: string, role: "owner" | "admin" | "member") { return this.request<{ member: Pick }>( "PATCH", `/teams/${encodeURIComponent(slug)}/members/${encodeURIComponent(memberId)}`, { role } ); } removeMember(slug: string, memberId: string) { return this.request<{ ok: boolean; removed: string; revokedVaultGrants: number; rotationRequired: boolean }>( "DELETE", `/teams/${encodeURIComponent(slug)}/members/${encodeURIComponent(memberId)}` ); } acceptInvite(token: string) { return this.request<{ ok: boolean; team?: RemoteTeam }>("POST", "/invites/accept", { token }); } // ---- vaults / grants / secrets ---- listVaults(slug: string) { return this.request<{ vaults: RemoteVault[] }>("GET", `/teams/${encodeURIComponent(slug)}/vaults`); } createVault(slug: string, name: string) { return this.request<{ vault: { id: string; name: string } }>("POST", `/teams/${encodeURIComponent(slug)}/vaults`, { name }); } getMyGrant(vaultId: string) { return this.request<{ wrappedDek: string }>("GET", `/vaults/${encodeURIComponent(vaultId)}/grant`); } listGrants(vaultId: string) { return this.request<{ grants: RemoteGrantRow[] }>("GET", `/vaults/${encodeURIComponent(vaultId)}/grants`); } putGrant(vaultId: string, email: string, wrappedDek: string) { return this.request<{ ok: boolean }>("POST", `/vaults/${encodeURIComponent(vaultId)}/grants`, { email, wrappedDek }); } listSecrets(vaultId: string) { return this.request<{ vaultId: string; secrets: RemoteSecret[] }>("GET", `/vaults/${encodeURIComponent(vaultId)}/secrets`); } putSecrets(vaultId: string, upserts: Array<{ name: string; nonce: string; ciphertext: string; fingerprint: string }>, deletes: string[]) { return this.request<{ ok: boolean; applied: string[] }>("PUT", `/vaults/${encodeURIComponent(vaultId)}/secrets`, { upserts, deletes }); } /** * Swap the vault to a fresh DEK. The whole next state goes over in one call * because the server applies it in a single transaction — see rekey.ts for * why a partial rotation is unrecoverable. */ rekeyVault( vaultId: string, body: { grants: Array<{ email: string; wrappedDek: string }>; secrets: Array<{ name: string; nonce: string; ciphertext: string; fingerprint: string }>; revoke: string[]; } ) { return this.request("POST", `/vaults/${encodeURIComponent(vaultId)}/rekey`, body); } listAudit(vaultId: string) { return this.request<{ audit: Array> }>("GET", `/vaults/${encodeURIComponent(vaultId)}/audit`); } } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }