mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 06:47:28 +00:00
207 lines
7 KiB
TypeScript
207 lines
7 KiB
TypeScript
/**
|
|
* 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<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
const headers: Record<string, string> = { 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<RemoteMember, "id" | "email" | "role" | "status"> }>(
|
|
"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<RekeyResult>("POST", `/vaults/${encodeURIComponent(vaultId)}/rekey`, body);
|
|
}
|
|
listAudit(vaultId: string) {
|
|
return this.request<{ audit: Array<Record<string, unknown>> }>("GET", `/vaults/${encodeURIComponent(vaultId)}/audit`);
|
|
}
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|