mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-15 07:17:30 +00:00
feat(credential-sharing): end-to-end-encrypted team credential sharing
Adds a `team` credential provider + team/member management so teammates can share secrets by email instead of passing .env files over chat. Fully E2E: the server only ever stores ciphertext, per-member sealed vault keys, and public keys — it never sees a plaintext value or the vault DEK. Plugin (@logicsrc/plugin-credential-sharing) - crypto.ts: X25519 identity keys, per-vault DEK (secretbox), DEK sealed to each member's pubkey (crypto_box_seal), value encrypt/decrypt (libsodium) - identity.ts: local ~/.logicsrc/identity.json (0600) holding the device key + API token; never uploads the secret key - client.ts: typed /api/credshare client - providers/team.ts: `team:<slug>/<vault>` CredentialProvider (inspect, readValues=decrypt, write=encrypt, rollback); fingerprints match env so env<->team diffs line up - fixes latent libsodium-wrappers ESM load bug (createRequire) here + in github-secrets Server (commandboard-api /api/credshare) - zero-knowledge router: email-code auth, keys, teams, members, invites, vaults, sealed grants, ciphertext secrets, audit; membership authz in app - CredShareStore abstraction: in-memory (dev/tests) + Supabase (prod) - Resend email transport for login codes + invites (no-op -> echoes locally) - supabase migration: credshare_* tables, deny-by-default RLS CLI - real `logicsrc login` (email code -> token + key upload) - `logicsrc teams create/list/invite/accept/members/vaults/grant/push/pull` Web (logicsrc.com/teams + /teams/accept) - management surface only (browser holds no private key, never decrypts): login, view teams/members/vaults, invite, accept Tests: crypto round-trip, server contract (invite->accept->push->grant->pull + authz boundaries), and a real HTTP+client+crypto E2E asserting the server never holds plaintext. Full workspace build + tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
257d581331
commit
f057589d66
28 changed files with 2869 additions and 17 deletions
|
|
@ -22,11 +22,13 @@
|
|||
"@logicsrc/plugin-social-accounts": "file:../../plugins/social-accounts",
|
||||
"@logicsrc/plugin-ugig": "file:../../plugins/ugig",
|
||||
"@logicsrc/validators": "file:../../packages/validators",
|
||||
"@supabase/supabase-js": "^2.105.4",
|
||||
"imapflow": "^1.4.3",
|
||||
"mailparser": "^3.9.12",
|
||||
"nodemailer": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@logicsrc/plugin-credential-sharing": "file:../../plugins/credential-sharing",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"tsx": "^4.21.0",
|
||||
|
|
|
|||
51
apps/commandboard-api/src/credshare/email.ts
Normal file
51
apps/commandboard-api/src/credshare/email.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type { CredShareEmailSender } from "./router.js";
|
||||
|
||||
/**
|
||||
* Resend-backed email transport for login codes and team invites.
|
||||
*
|
||||
* Returns `undefined` when RESEND_API_KEY is not configured, so the API falls
|
||||
* back to echoing codes/tokens in its responses (fine for local dev; production
|
||||
* sets the key and never echoes secrets).
|
||||
*/
|
||||
export function createResendEmailSender(options: { webBaseUrl: string } = { webBaseUrl: "https://logicsrc.com" }): CredShareEmailSender | undefined {
|
||||
const apiKey = process.env.RESEND_API_KEY;
|
||||
if (!apiKey) return undefined;
|
||||
const from = process.env.CREDSHARE_EMAIL_FROM || "LogicSRC <noreply@logicsrc.com>";
|
||||
const webBaseUrl = process.env.LOGICSRC_WEB_URL || options.webBaseUrl;
|
||||
|
||||
async function send(to: string, subject: string, html: string, text: string): Promise<void> {
|
||||
const response = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
|
||||
body: JSON.stringify({ from, to, subject, html, text })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => "");
|
||||
throw new Error(`Resend send failed: ${response.status} ${body.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async sendLoginCode(email, code) {
|
||||
await send(
|
||||
email,
|
||||
"Your LogicSRC login code",
|
||||
`<p>Your LogicSRC login code is:</p><p style="font-size:24px;font-weight:bold;letter-spacing:3px">${code}</p><p>It expires in 10 minutes. If you didn't request this, ignore this email.</p>`,
|
||||
`Your LogicSRC login code is: ${code}\nIt expires in 10 minutes.`
|
||||
);
|
||||
},
|
||||
async sendInvite({ email, token, teamName, teamSlug, invitedByEmail }) {
|
||||
const acceptUrl = `${webBaseUrl}/teams/accept?token=${encodeURIComponent(token)}`;
|
||||
await send(
|
||||
email,
|
||||
`You're invited to the "${teamName}" credential team on LogicSRC`,
|
||||
`<p><strong>${invitedByEmail}</strong> invited you to share credentials on the <strong>${teamName}</strong> (<code>${teamSlug}</code>) team.</p>
|
||||
<p>Accept in the CLI:</p>
|
||||
<pre>logicsrc login --email ${email}
|
||||
logicsrc teams accept ${token}</pre>
|
||||
<p>…or <a href="${acceptUrl}">accept on the web</a>. Secrets stay end-to-end encrypted — the server never sees them.</p>`,
|
||||
`${invitedByEmail} invited you to the "${teamName}" (${teamSlug}) credential team on LogicSRC.\n\nAccept in the CLI:\n logicsrc login --email ${email}\n logicsrc teams accept ${token}\n\nOr on the web: ${acceptUrl}`
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
132
apps/commandboard-api/src/credshare/integration.test.ts
Normal file
132
apps/commandboard-api/src/credshare/integration.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { createServer, type Server, type IncomingMessage } from "node:http";
|
||||
import { createCredShareApi, type CredShareRequest } from "./router.js";
|
||||
import { createMemoryCredShareStore } from "./store.js";
|
||||
import {
|
||||
TeamClient,
|
||||
generateIdentityKeyPair,
|
||||
generateVaultKey,
|
||||
wrapVaultKey,
|
||||
unwrapVaultKey,
|
||||
encryptValue,
|
||||
decryptValue,
|
||||
fingerprintValue
|
||||
} from "@logicsrc/plugin-credential-sharing";
|
||||
|
||||
/**
|
||||
* True end-to-end test: the real HTTP server + the real TeamClient + real
|
||||
* libsodium crypto. Alice shares a .env with Bob purely through the server, and
|
||||
* we assert the server never held plaintext while Bob still recovers the values.
|
||||
*/
|
||||
describe("credshare end-to-end (server + client + crypto)", () => {
|
||||
let server: Server;
|
||||
let apiUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Hermetic server: real router + memory store, no email transport (so the
|
||||
// dev-code echo path is deterministic and independent of ambient env).
|
||||
const api = createCredShareApi({ store: createMemoryCredShareStore() });
|
||||
server = createServer(async (request, response) => {
|
||||
const url = new URL(request.url ?? "/", "http://localhost");
|
||||
const method = request.method ?? "GET";
|
||||
let body: unknown;
|
||||
if (method === "POST" || method === "PUT" || method === "PATCH") {
|
||||
body = await readBody(request);
|
||||
}
|
||||
const header = request.headers["authorization"];
|
||||
const token = header?.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : undefined;
|
||||
const req: CredShareRequest = { method, path: url.pathname.replace(/^\/api\/credshare/, "") || "/", query: url.searchParams, body, token };
|
||||
const result = await api.handle(req);
|
||||
response.writeHead(result.status, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify(result.body));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
async function readBody(request: IncomingMessage): Promise<unknown> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
||||
const text = Buffer.concat(chunks).toString("utf8");
|
||||
return text ? JSON.parse(text) : undefined;
|
||||
}
|
||||
|
||||
async function loginClient(email: string) {
|
||||
const client = new TeamClient({ apiUrl });
|
||||
const req = await client.requestLoginCode(email);
|
||||
const verify = await client.verifyLoginCode(email, req.devCode!);
|
||||
client.setToken(verify.token);
|
||||
return client;
|
||||
}
|
||||
|
||||
it("shares an env bundle from Alice to Bob without the server ever seeing plaintext", async () => {
|
||||
const secretEnv = { DATABASE_URL: "postgres://user:pw@host/db", STRIPE_KEY: "sk_live_deadbeef" };
|
||||
|
||||
// --- Alice sets up ---
|
||||
const alice = await loginClient("alice@acme.dev");
|
||||
const aliceKeys = await generateIdentityKeyPair();
|
||||
await alice.uploadPublicKey(aliceKeys.publicKey);
|
||||
await alice.createTeam("acme-e2e", "Acme");
|
||||
const { vault } = await alice.createVault("acme-e2e", "prod");
|
||||
|
||||
// Alice mints the vault DEK, wraps it to herself, and pushes ciphertext.
|
||||
const dek = await generateVaultKey();
|
||||
await alice.putGrant(vault.id, "alice@acme.dev", await wrapVaultKey(dek, aliceKeys.publicKey));
|
||||
const upserts = [] as Array<{ name: string; nonce: string; ciphertext: string; fingerprint: string }>;
|
||||
for (const [name, value] of Object.entries(secretEnv)) {
|
||||
const sealed = await encryptValue(value, dek);
|
||||
upserts.push({ name, ...sealed, fingerprint: fingerprintValue(value) });
|
||||
}
|
||||
await alice.putSecrets(vault.id, upserts, []);
|
||||
|
||||
// The server must only hold ciphertext — assert no plaintext leaks over the wire.
|
||||
const stored = await alice.listSecrets(vault.id);
|
||||
for (const s of stored.secrets) {
|
||||
expect(s.ciphertext).not.toContain("postgres://");
|
||||
expect(s.ciphertext).not.toContain("sk_live");
|
||||
}
|
||||
|
||||
// --- Bob joins ---
|
||||
const bob = await loginClient("bob@acme.dev");
|
||||
const bobKeys = await generateIdentityKeyPair();
|
||||
await bob.uploadPublicKey(bobKeys.publicKey);
|
||||
const invite = await alice.invite("acme-e2e", "bob@acme.dev");
|
||||
await bob.acceptInvite(invite.token!);
|
||||
|
||||
// --- Alice grants Bob: look up Bob's pubkey, re-wrap the DEK to it ---
|
||||
const bobUser = await alice.lookupUser("bob@acme.dev");
|
||||
const aliceDek = await unwrapVaultKey((await alice.getMyGrant(vault.id)).wrappedDek, aliceKeys);
|
||||
await alice.putGrant(vault.id, "bob@acme.dev", await wrapVaultKey(aliceDek, bobUser.publicKey!));
|
||||
|
||||
// --- Bob pulls + decrypts on his machine ---
|
||||
const bobDek = await unwrapVaultKey((await bob.getMyGrant(vault.id)).wrappedDek, bobKeys);
|
||||
const bobSecrets = await bob.listSecrets(vault.id);
|
||||
const recovered: Record<string, string> = {};
|
||||
for (const s of bobSecrets.secrets) {
|
||||
recovered[s.name] = await decryptValue({ nonce: s.nonce, ciphertext: s.ciphertext }, bobDek);
|
||||
}
|
||||
expect(recovered).toEqual(secretEnv);
|
||||
});
|
||||
|
||||
it("denies decryption to a member who has not been granted the DEK", async () => {
|
||||
const alice = await loginClient("alice2@acme.dev");
|
||||
const aliceKeys = await generateIdentityKeyPair();
|
||||
await alice.uploadPublicKey(aliceKeys.publicKey);
|
||||
await alice.createTeam("acme-e2e-2", "Acme2");
|
||||
const { vault } = await alice.createVault("acme-e2e-2", "prod");
|
||||
const dek = await generateVaultKey();
|
||||
await alice.putGrant(vault.id, "alice2@acme.dev", await wrapVaultKey(dek, aliceKeys.publicKey));
|
||||
|
||||
const carol = await loginClient("carol@acme.dev");
|
||||
await carol.uploadPublicKey((await generateIdentityKeyPair()).publicKey);
|
||||
const invite = await alice.invite("acme-e2e-2", "carol@acme.dev");
|
||||
await carol.acceptInvite(invite.token!);
|
||||
|
||||
// Carol is a member (can see ciphertext) but has no grant → cannot get a DEK.
|
||||
await expect(carol.getMyGrant(vault.id)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
134
apps/commandboard-api/src/credshare/router.test.ts
Normal file
134
apps/commandboard-api/src/credshare/router.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { createMemoryCredShareStore } from "./store.js";
|
||||
import { createCredShareApi, type CredShareRequest } from "./router.js";
|
||||
|
||||
function api() {
|
||||
return createCredShareApi({ store: createMemoryCredShareStore() });
|
||||
}
|
||||
|
||||
async function login(app: ReturnType<typeof createCredShareApi>, email: string): Promise<string> {
|
||||
const req = await app.handle(reqOf("POST", "/auth/request", { body: { email } }));
|
||||
const code = (req.body as { devCode: string }).devCode;
|
||||
const verify = await app.handle(reqOf("POST", "/auth/verify", { body: { email, code } }));
|
||||
return (verify.body as { token: string }).token;
|
||||
}
|
||||
|
||||
function reqOf(method: string, path: string, opts: { body?: unknown; token?: string; query?: Record<string, string> } = {}): CredShareRequest {
|
||||
return {
|
||||
method,
|
||||
path,
|
||||
query: new URLSearchParams(opts.query ?? {}),
|
||||
body: opts.body,
|
||||
token: opts.token
|
||||
};
|
||||
}
|
||||
|
||||
describe("credshare API", () => {
|
||||
let app: ReturnType<typeof createCredShareApi>;
|
||||
beforeEach(() => {
|
||||
app = api();
|
||||
});
|
||||
|
||||
it("issues a token via the email-code flow and rejects a bad code", async () => {
|
||||
const request = await app.handle(reqOf("POST", "/auth/request", { body: { email: "Owner@Example.com" } }));
|
||||
expect(request.status).toBe(200);
|
||||
const code = (request.body as { devCode: string }).devCode;
|
||||
expect(code).toMatch(/^\d{6}$/);
|
||||
|
||||
const bad = await app.handle(reqOf("POST", "/auth/verify", { body: { email: "owner@example.com", code: "000000" } }));
|
||||
expect(bad.status).toBe(401);
|
||||
|
||||
const good = await app.handle(reqOf("POST", "/auth/verify", { body: { email: "owner@example.com", code } }));
|
||||
expect(good.status).toBe(200);
|
||||
expect((good.body as { user: { email: string } }).user.email).toBe("owner@example.com");
|
||||
});
|
||||
|
||||
it("rejects protected routes without a token", async () => {
|
||||
const res = await app.handle(reqOf("GET", "/teams"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("runs the full invite → accept → push → grant → pull loop", async () => {
|
||||
const ownerToken = await login(app, "owner@example.com");
|
||||
const bobToken = await login(app, "bob@example.com");
|
||||
|
||||
// Both members upload public keys.
|
||||
await app.handle(reqOf("POST", "/keys", { token: ownerToken, body: { publicKey: "OWNER_PUBKEY_B64" } }));
|
||||
await app.handle(reqOf("POST", "/keys", { token: bobToken, body: { publicKey: "BOB_PUBKEY_B64" } }));
|
||||
|
||||
// Owner creates a team + vault.
|
||||
const team = await app.handle(reqOf("POST", "/teams", { token: ownerToken, body: { slug: "acme", name: "Acme" } }));
|
||||
expect(team.status).toBe(201);
|
||||
const vaultRes = await app.handle(reqOf("POST", "/teams/acme/vaults", { token: ownerToken, body: { name: "prod" } }));
|
||||
const vaultId = (vaultRes.body as { vault: { id: string } }).vault.id;
|
||||
|
||||
// Owner self-grants (wraps DEK to own key) and pushes ciphertext.
|
||||
await app.handle(reqOf("POST", `/vaults/${vaultId}/grants`, { token: ownerToken, body: { email: "owner@example.com", wrappedDek: "DEK_SEALED_TO_OWNER" } }));
|
||||
const push = await app.handle(
|
||||
reqOf("PUT", `/vaults/${vaultId}/secrets`, {
|
||||
token: ownerToken,
|
||||
body: { upserts: [{ name: "DATABASE_URL", nonce: "n1", ciphertext: "c1", fingerprint: "fp1" }] }
|
||||
})
|
||||
);
|
||||
expect(push.status).toBe(200);
|
||||
|
||||
// Bob can't access yet — no grant.
|
||||
const bobEarly = await app.handle(reqOf("GET", `/vaults/${vaultId}/grant`, { token: bobToken }));
|
||||
expect(bobEarly.status).toBe(403);
|
||||
|
||||
// Owner invites Bob; Bob accepts.
|
||||
const invite = await app.handle(reqOf("POST", "/teams/acme/invites", { token: ownerToken, body: { email: "bob@example.com" } }));
|
||||
expect(invite.status).toBe(201);
|
||||
const inviteToken = (invite.body as { token: string }).token;
|
||||
const accept = await app.handle(reqOf("POST", "/invites/accept", { token: bobToken, body: { token: inviteToken } }));
|
||||
expect(accept.status).toBe(200);
|
||||
|
||||
// Bob is now a member and can read ciphertext, but still needs a grant to decrypt.
|
||||
const members = await app.handle(reqOf("GET", "/teams/acme/members", { token: bobToken }));
|
||||
expect((members.body as { members: unknown[] }).members).toHaveLength(2);
|
||||
|
||||
// Owner grants Bob (re-wraps DEK to Bob's pubkey).
|
||||
const grantBob = await app.handle(reqOf("POST", `/vaults/${vaultId}/grants`, { token: ownerToken, body: { email: "bob@example.com", wrappedDek: "DEK_SEALED_TO_BOB" } }));
|
||||
expect(grantBob.status).toBe(201);
|
||||
|
||||
// Bob pulls his wrapped DEK and the ciphertext.
|
||||
const bobGrant = await app.handle(reqOf("GET", `/vaults/${vaultId}/grant`, { token: bobToken }));
|
||||
expect((bobGrant.body as { wrappedDek: string }).wrappedDek).toBe("DEK_SEALED_TO_BOB");
|
||||
const secrets = await app.handle(reqOf("GET", `/vaults/${vaultId}/secrets`, { token: bobToken }));
|
||||
expect((secrets.body as { secrets: { name: string }[] }).secrets[0].name).toBe("DATABASE_URL");
|
||||
});
|
||||
|
||||
it("stops a non-admin from inviting and enforces invite-email match", async () => {
|
||||
const ownerToken = await login(app, "owner@example.com");
|
||||
const bobToken = await login(app, "bob@example.com");
|
||||
const eveToken = await login(app, "eve@example.com");
|
||||
await app.handle(reqOf("POST", "/keys", { token: bobToken, body: { publicKey: "BOB" } }));
|
||||
await app.handle(reqOf("POST", "/teams", { token: ownerToken, body: { slug: "acme", name: "Acme" } }));
|
||||
|
||||
// Invite Bob as a plain member; Bob accepts.
|
||||
const inv = await app.handle(reqOf("POST", "/teams/acme/invites", { token: ownerToken, body: { email: "bob@example.com" } }));
|
||||
await app.handle(reqOf("POST", "/invites/accept", { token: bobToken, body: { token: (inv.body as { token: string }).token } }));
|
||||
|
||||
// Bob (member) cannot invite.
|
||||
const bobInvite = await app.handle(reqOf("POST", "/teams/acme/invites", { token: bobToken, body: { email: "carol@example.com" } }));
|
||||
expect(bobInvite.status).toBe(403);
|
||||
|
||||
// Eve can't accept Bob's-email invite.
|
||||
const inv2 = await app.handle(reqOf("POST", "/teams/acme/invites", { token: ownerToken, body: { email: "carol@example.com" } }));
|
||||
const eveAccept = await app.handle(reqOf("POST", "/invites/accept", { token: eveToken, body: { token: (inv2.body as { token: string }).token } }));
|
||||
expect(eveAccept.status).toBe(403);
|
||||
});
|
||||
|
||||
it("blocks a non-member from a team's vault entirely", async () => {
|
||||
const ownerToken = await login(app, "owner@example.com");
|
||||
const strangerToken = await login(app, "stranger@example.com");
|
||||
await app.handle(reqOf("POST", "/teams", { token: ownerToken, body: { slug: "acme" } }));
|
||||
const v = await app.handle(reqOf("POST", "/teams/acme/vaults", { token: ownerToken, body: { name: "prod" } }));
|
||||
const vaultId = (v.body as { vault: { id: string } }).vault.id;
|
||||
|
||||
const stranger = await app.handle(reqOf("GET", `/vaults/${vaultId}/secrets`, { token: strangerToken }));
|
||||
expect(stranger.status).toBe(403);
|
||||
const strangerMembers = await app.handle(reqOf("GET", "/teams/acme/members", { token: strangerToken }));
|
||||
expect(strangerMembers.status).toBe(403);
|
||||
});
|
||||
});
|
||||
391
apps/commandboard-api/src/credshare/router.ts
Normal file
391
apps/commandboard-api/src/credshare/router.ts
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
import { randomBytes, createHash } from "node:crypto";
|
||||
import { hashToken, normalizeEmail, type CredShareStore } from "./store.js";
|
||||
import type { CredShareMember, MemberRole } from "./types.js";
|
||||
|
||||
export interface CredShareRequest {
|
||||
method: string;
|
||||
/** Path AFTER the /api/credshare prefix, e.g. "/teams/acme/members". */
|
||||
path: string;
|
||||
query: URLSearchParams;
|
||||
body: unknown;
|
||||
/** Bearer token (without the "Bearer " prefix), if present. */
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface CredShareResponse {
|
||||
status: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
export interface CredShareEmailSender {
|
||||
sendLoginCode(email: string, code: string): Promise<void>;
|
||||
sendInvite(input: { email: string; token: string; teamName: string; teamSlug: string; invitedByEmail: string }): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CredShareApiOptions {
|
||||
store: CredShareStore;
|
||||
email?: CredShareEmailSender;
|
||||
/** Public base URL used to build invite-accept links in emails. */
|
||||
webBaseUrl?: string;
|
||||
now?: () => Date;
|
||||
/** Test seams. */
|
||||
generateCode?: () => string;
|
||||
generateToken?: () => string;
|
||||
loginCodeTtlMs?: number;
|
||||
inviteTtlMs?: number;
|
||||
}
|
||||
|
||||
const ROLE_RANK: Record<MemberRole, number> = { member: 0, admin: 1, owner: 2 };
|
||||
|
||||
function ok(body: unknown, status = 200): CredShareResponse {
|
||||
return { status, body };
|
||||
}
|
||||
function err(status: number, message: string): CredShareResponse {
|
||||
return { status, body: { error: message } };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function str(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/** A member view safe to return to teammates (no tokens/keys, just identity + status). */
|
||||
function memberView(m: CredShareMember, publicKey: string | null | undefined, hasGrant?: boolean) {
|
||||
return {
|
||||
email: m.email,
|
||||
role: m.role,
|
||||
status: m.status,
|
||||
hasPublicKey: Boolean(publicKey),
|
||||
...(hasGrant === undefined ? {} : { hasVaultAccess: hasGrant }),
|
||||
joinedAt: m.joinedAt
|
||||
};
|
||||
}
|
||||
|
||||
export function createCredShareApi(options: CredShareApiOptions) {
|
||||
const { store, email } = options;
|
||||
const now = options.now ?? (() => new Date());
|
||||
const generateCode = options.generateCode ?? (() => String(randomBytes(4).readUInt32BE(0) % 1_000_000).padStart(6, "0"));
|
||||
const generateToken = options.generateToken ?? (() => randomBytes(32).toString("base64url"));
|
||||
const loginCodeTtlMs = options.loginCodeTtlMs ?? 10 * 60 * 1000;
|
||||
const inviteTtlMs = options.inviteTtlMs ?? 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
async function currentUserId(req: CredShareRequest): Promise<string | undefined> {
|
||||
if (!req.token) return undefined;
|
||||
return store.getUserIdForToken(hashToken(req.token));
|
||||
}
|
||||
|
||||
type MemberGuard =
|
||||
| { ok: true; team: Awaited<ReturnType<CredShareStore["getTeamBySlug"]>> & object; member: CredShareMember }
|
||||
| { ok: false; response: CredShareResponse };
|
||||
|
||||
async function requireMember(teamSlug: string, userId: string): Promise<MemberGuard> {
|
||||
const team = await store.getTeamBySlug(teamSlug);
|
||||
if (!team) return { ok: false, response: err(404, `Unknown team: ${teamSlug}`) };
|
||||
const member = await store.getMember(team.id, userId);
|
||||
if (!member || member.status !== "active") {
|
||||
return { ok: false, response: err(403, "You are not a member of this team.") };
|
||||
}
|
||||
return { ok: true, team, member };
|
||||
}
|
||||
|
||||
async function handle(req: CredShareRequest): Promise<CredShareResponse> {
|
||||
const { method, path } = req;
|
||||
|
||||
// ---- auth (unauthenticated) ------------------------------------------
|
||||
if (method === "POST" && path === "/auth/request") {
|
||||
if (!isRecord(req.body) || !str(req.body.email)) return err(422, "Expected { email }.");
|
||||
const emailAddr = normalizeEmail(str(req.body.email)!);
|
||||
const code = generateCode();
|
||||
await store.saveLoginCode({
|
||||
email: emailAddr,
|
||||
codeHash: createHash("sha256").update(code).digest("hex"),
|
||||
expiresAt: new Date(now().getTime() + loginCodeTtlMs).toISOString(),
|
||||
createdAt: now().toISOString()
|
||||
});
|
||||
let delivered = false;
|
||||
if (email) {
|
||||
await email.sendLoginCode(emailAddr, code);
|
||||
delivered = true;
|
||||
}
|
||||
// When no email transport is configured (local dev/tests), echo the code so
|
||||
// the flow is still usable. Never echo it once real delivery is on.
|
||||
return ok({ ok: true, emailSent: delivered, ...(delivered ? {} : { devCode: code }) });
|
||||
}
|
||||
|
||||
if (method === "POST" && path === "/auth/verify") {
|
||||
if (!isRecord(req.body) || !str(req.body.email) || !str(req.body.code)) return err(422, "Expected { email, code }.");
|
||||
const emailAddr = normalizeEmail(str(req.body.email)!);
|
||||
const codeHash = createHash("sha256").update(str(req.body.code)!).digest("hex");
|
||||
const valid = await store.consumeLoginCode(emailAddr, codeHash, now());
|
||||
if (!valid) return err(401, "Invalid or expired code.");
|
||||
const user = await store.upsertUserByEmail(emailAddr);
|
||||
const token = generateToken();
|
||||
await store.saveToken({ tokenHash: hashToken(token), userId: user.id, createdAt: now().toISOString(), lastUsedAt: null });
|
||||
return ok({ token, user: { id: user.id, email: user.email, publicKey: user.publicKey } });
|
||||
}
|
||||
|
||||
// ---- everything below requires a valid token -------------------------
|
||||
const userId = await currentUserId(req);
|
||||
if (!userId) return err(401, "Missing or invalid token. Run: logicsrc login --email you@example.com");
|
||||
const me = await store.getUserById(userId);
|
||||
if (!me) return err(401, "Unknown user for token.");
|
||||
|
||||
if (method === "POST" && path === "/logout") {
|
||||
if (req.token) await store.deleteToken(hashToken(req.token));
|
||||
return ok({ ok: true });
|
||||
}
|
||||
|
||||
if (method === "POST" && path === "/keys") {
|
||||
if (!isRecord(req.body) || !str(req.body.publicKey)) return err(422, "Expected { publicKey }.");
|
||||
const updated = await store.setUserPublicKey(userId, str(req.body.publicKey)!);
|
||||
return ok({ email: updated.email, publicKey: updated.publicKey });
|
||||
}
|
||||
|
||||
if (method === "GET" && path === "/me") {
|
||||
const teams = await store.listTeamsForUser(userId);
|
||||
return ok({ user: { id: me.id, email: me.email, publicKey: me.publicKey }, teams });
|
||||
}
|
||||
|
||||
if (method === "GET" && path === "/users") {
|
||||
const target = req.query.get("email");
|
||||
if (!target) return err(422, "Expected ?email=");
|
||||
const user = await store.getUserByEmail(target);
|
||||
return ok({ email: normalizeEmail(target), userId: user?.id ?? null, publicKey: user?.publicKey ?? null });
|
||||
}
|
||||
|
||||
// ---- teams -----------------------------------------------------------
|
||||
if (method === "POST" && path === "/teams") {
|
||||
if (!isRecord(req.body) || !str(req.body.slug)) return err(422, "Expected { slug, name? }.");
|
||||
const slug = normalizeSlug(str(req.body.slug)!);
|
||||
if (!slug) return err(422, "Slug must be lowercase letters, numbers, and dashes.");
|
||||
if (await store.getTeamBySlug(slug)) return err(409, `Team slug "${slug}" is taken.`);
|
||||
const team = await store.createTeam({ slug, name: str(req.body.name) ?? slug, createdBy: userId });
|
||||
await store.addMember({
|
||||
teamId: team.id,
|
||||
userId,
|
||||
email: me.email,
|
||||
role: "owner",
|
||||
status: "active",
|
||||
invitedBy: null,
|
||||
joinedAt: now().toISOString()
|
||||
});
|
||||
await store.appendAudit({ teamId: team.id, vaultId: null, actorUserId: userId, action: "team:create", keyName: null, fingerprint: null });
|
||||
return ok({ team }, 201);
|
||||
}
|
||||
|
||||
if (method === "GET" && path === "/teams") {
|
||||
return ok({ teams: await store.listTeamsForUser(userId) });
|
||||
}
|
||||
|
||||
const membersMatch = /^\/teams\/([^/]+)\/members$/.exec(path);
|
||||
if (membersMatch && method === "GET") {
|
||||
const guard = await requireMember(decodeURIComponent(membersMatch[1]), userId);
|
||||
if (!guard.ok) return guard.response;
|
||||
const rows = await store.listMembers(guard.team.id);
|
||||
const views = await Promise.all(
|
||||
rows.map(async (m) => {
|
||||
const u = m.userId ? await store.getUserById(m.userId) : undefined;
|
||||
return memberView(m, u?.publicKey);
|
||||
})
|
||||
);
|
||||
return ok({ members: views });
|
||||
}
|
||||
|
||||
// ---- invites ---------------------------------------------------------
|
||||
const invitesMatch = /^\/teams\/([^/]+)\/invites$/.exec(path);
|
||||
if (invitesMatch && method === "POST") {
|
||||
const guard = await requireMember(decodeURIComponent(invitesMatch[1]), userId);
|
||||
if (!guard.ok) return guard.response;
|
||||
if (ROLE_RANK[guard.member.role] < ROLE_RANK.admin) return err(403, "Only owners and admins can invite.");
|
||||
if (!isRecord(req.body) || !str(req.body.email)) return err(422, "Expected { email, role? }.");
|
||||
const inviteEmail = normalizeEmail(str(req.body.email)!);
|
||||
const role = (str(req.body.role) as MemberRole) ?? "member";
|
||||
if (!(role in ROLE_RANK)) return err(422, "role must be owner|admin|member.");
|
||||
|
||||
// Ensure a (possibly invited) member row exists.
|
||||
const existing = await store.getMemberByEmail(guard.team.id, inviteEmail);
|
||||
if (!existing) {
|
||||
const invitedUser = await store.getUserByEmail(inviteEmail);
|
||||
await store.addMember({
|
||||
teamId: guard.team.id,
|
||||
userId: invitedUser?.id ?? null,
|
||||
email: inviteEmail,
|
||||
role,
|
||||
status: "invited",
|
||||
invitedBy: userId,
|
||||
joinedAt: null
|
||||
});
|
||||
}
|
||||
const token = generateToken();
|
||||
const invite = await store.createInvite({
|
||||
teamId: guard.team.id,
|
||||
email: inviteEmail,
|
||||
role,
|
||||
tokenHash: hashToken(token),
|
||||
createdBy: userId,
|
||||
expiresAt: new Date(now().getTime() + inviteTtlMs).toISOString()
|
||||
});
|
||||
await store.appendAudit({ teamId: guard.team.id, vaultId: null, actorUserId: userId, action: "team:invite", keyName: inviteEmail, fingerprint: null });
|
||||
let delivered = false;
|
||||
if (email) {
|
||||
await email.sendInvite({ email: inviteEmail, token, teamName: guard.team.name, teamSlug: guard.team.slug, invitedByEmail: me.email });
|
||||
delivered = true;
|
||||
}
|
||||
return ok(
|
||||
{ invite: { id: invite.id, email: invite.email, role: invite.role, expiresAt: invite.expiresAt }, emailSent: delivered, ...(delivered ? {} : { token }) },
|
||||
201
|
||||
);
|
||||
}
|
||||
|
||||
if (method === "POST" && path === "/invites/accept") {
|
||||
if (!isRecord(req.body) || !str(req.body.token)) return err(422, "Expected { token }.");
|
||||
const invite = await store.getInviteByTokenHash(hashToken(str(req.body.token)!));
|
||||
if (!invite) return err(404, "Invite not found.");
|
||||
if (invite.acceptedAt) return err(409, "Invite already used.");
|
||||
if (new Date(invite.expiresAt).getTime() < now().getTime()) return err(410, "Invite expired.");
|
||||
if (invite.email !== me.email) return err(403, `This invite is for ${invite.email}, not ${me.email}.`);
|
||||
await store.updateMember(invite.teamId, me.email, { userId, status: "active", joinedAt: now().toISOString() });
|
||||
await store.markInviteAccepted(invite.id, now().toISOString());
|
||||
await store.appendAudit({ teamId: invite.teamId, vaultId: null, actorUserId: userId, action: "team:join", keyName: null, fingerprint: null });
|
||||
const team = (await store.listTeamsForUser(userId)).find((t) => t.id === invite.teamId);
|
||||
return ok({ ok: true, team });
|
||||
}
|
||||
|
||||
// ---- vaults ----------------------------------------------------------
|
||||
const vaultsMatch = /^\/teams\/([^/]+)\/vaults$/.exec(path);
|
||||
if (vaultsMatch) {
|
||||
const guard = await requireMember(decodeURIComponent(vaultsMatch[1]), userId);
|
||||
if (!guard.ok) return guard.response;
|
||||
if (method === "GET") {
|
||||
const vaults = await store.listVaults(guard.team.id);
|
||||
const rows = await Promise.all(
|
||||
vaults.map(async (v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
hasAccess: Boolean(await store.getGrant(v.id, userId)),
|
||||
secretCount: (await store.listSecrets(v.id)).length
|
||||
}))
|
||||
);
|
||||
return ok({ vaults: rows });
|
||||
}
|
||||
if (method === "POST") {
|
||||
if (!isRecord(req.body) || !str(req.body.name)) return err(422, "Expected { name }.");
|
||||
const name = normalizeSlug(str(req.body.name)!);
|
||||
if (!name) return err(422, "Vault name must be lowercase letters, numbers, and dashes.");
|
||||
const existing = await store.getVault(guard.team.id, name);
|
||||
if (existing) return ok({ vault: existing });
|
||||
const vault = await store.createVault({ teamId: guard.team.id, name, createdBy: userId });
|
||||
await store.appendAudit({ teamId: guard.team.id, vaultId: vault.id, actorUserId: userId, action: "vault:create", keyName: null, fingerprint: null });
|
||||
return ok({ vault }, 201);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- vault grants / secrets / audit (addressed by vault id) ----------
|
||||
const vaultIdMatch = /^\/vaults\/([^/]+)\/(grant|grants|secrets|audit)$/.exec(path);
|
||||
if (vaultIdMatch) {
|
||||
const vaultId = decodeURIComponent(vaultIdMatch[1]);
|
||||
const sub = vaultIdMatch[2];
|
||||
const vault = await store.getVaultById(vaultId);
|
||||
if (!vault) return err(404, "Unknown vault.");
|
||||
const member = await store.getMember(vault.teamId, userId);
|
||||
if (!member || member.status !== "active") return err(403, "You are not a member of this vault's team.");
|
||||
|
||||
if (sub === "grant" && method === "GET") {
|
||||
const grant = await store.getGrant(vaultId, userId);
|
||||
if (!grant) return err(403, "You do not have access to this vault yet. Ask a member to grant you.");
|
||||
return ok({ wrappedDek: grant.wrappedDek });
|
||||
}
|
||||
|
||||
if (sub === "grants") {
|
||||
if (method === "GET") {
|
||||
const grants = await store.listGrants(vaultId);
|
||||
const grantedUserIds = new Set(grants.map((g) => g.userId));
|
||||
const members = await store.listMembers(vault.teamId);
|
||||
const rows = await Promise.all(
|
||||
members.map(async (m) => {
|
||||
const u = m.userId ? await store.getUserById(m.userId) : undefined;
|
||||
return { email: m.email, hasPublicKey: Boolean(u?.publicKey), hasAccess: Boolean(m.userId && grantedUserIds.has(m.userId)) };
|
||||
})
|
||||
);
|
||||
return ok({ grants: rows });
|
||||
}
|
||||
if (method === "POST") {
|
||||
// The caller must already hold access (they can produce a valid wrapped DEK).
|
||||
const iHold = await store.getGrant(vaultId, userId);
|
||||
if (!iHold && (await store.listGrants(vaultId)).length > 0) {
|
||||
return err(403, "Only a member with vault access can grant others.");
|
||||
}
|
||||
if (!isRecord(req.body) || !str(req.body.wrappedDek) || !str(req.body.email)) {
|
||||
return err(422, "Expected { email, wrappedDek }.");
|
||||
}
|
||||
const targetUser = await store.getUserByEmail(str(req.body.email)!);
|
||||
if (!targetUser) return err(404, "Target user has not logged in yet.");
|
||||
if (!targetUser.publicKey) return err(409, "Target user has not uploaded a public key yet.");
|
||||
await store.upsertGrant({ vaultId, userId: targetUser.id, wrappedDek: str(req.body.wrappedDek)!, grantedBy: userId, createdAt: now().toISOString() });
|
||||
await store.appendAudit({ teamId: vault.teamId, vaultId, actorUserId: userId, action: "vault:grant", keyName: targetUser.email, fingerprint: null });
|
||||
return ok({ ok: true }, 201);
|
||||
}
|
||||
}
|
||||
|
||||
if (sub === "secrets") {
|
||||
if (method === "GET") {
|
||||
const secrets = await store.listSecrets(vaultId);
|
||||
return ok({
|
||||
vaultId,
|
||||
secrets: secrets.map((s) => ({ name: s.name, nonce: s.nonce, ciphertext: s.ciphertext, fingerprint: s.fingerprint, version: s.version, updatedAt: s.updatedAt }))
|
||||
});
|
||||
}
|
||||
if (method === "PUT") {
|
||||
if (!isRecord(req.body)) return err(422, "Expected { upserts?, deletes? }.");
|
||||
const upserts = Array.isArray(req.body.upserts) ? req.body.upserts : [];
|
||||
const deletes = Array.isArray(req.body.deletes) ? req.body.deletes : [];
|
||||
const existing = new Map((await store.listSecrets(vaultId)).map((s) => [s.name, s]));
|
||||
const applied: string[] = [];
|
||||
for (const raw of upserts) {
|
||||
if (!isRecord(raw) || !str(raw.name) || !str(raw.nonce) || !str(raw.ciphertext) || !str(raw.fingerprint)) {
|
||||
return err(422, "Each upsert needs { name, nonce, ciphertext, fingerprint }.");
|
||||
}
|
||||
const name = str(raw.name)!;
|
||||
const prev = existing.get(name);
|
||||
await store.putSecret({
|
||||
vaultId,
|
||||
name,
|
||||
nonce: str(raw.nonce)!,
|
||||
ciphertext: str(raw.ciphertext)!,
|
||||
fingerprint: str(raw.fingerprint)!,
|
||||
version: (prev?.version ?? 0) + 1,
|
||||
updatedBy: userId,
|
||||
updatedAt: now().toISOString()
|
||||
});
|
||||
await store.appendAudit({ teamId: vault.teamId, vaultId, actorUserId: userId, action: prev ? "secret:update" : "secret:add", keyName: name, fingerprint: str(raw.fingerprint)! });
|
||||
applied.push(name);
|
||||
}
|
||||
for (const raw of deletes) {
|
||||
const name = str(raw);
|
||||
if (!name) continue;
|
||||
await store.deleteSecret(vaultId, name);
|
||||
await store.appendAudit({ teamId: vault.teamId, vaultId, actorUserId: userId, action: "secret:remove", keyName: name, fingerprint: null });
|
||||
applied.push(name);
|
||||
}
|
||||
return ok({ ok: true, applied });
|
||||
}
|
||||
}
|
||||
|
||||
if (sub === "audit" && method === "GET") {
|
||||
return ok({ audit: await store.listAudit(vaultId) });
|
||||
}
|
||||
}
|
||||
|
||||
return err(404, "Not found");
|
||||
}
|
||||
|
||||
return { handle };
|
||||
}
|
||||
|
||||
function normalizeSlug(input: string): string | undefined {
|
||||
const slug = input.trim().toLowerCase();
|
||||
return /^[a-z0-9][a-z0-9-]{0,62}$/.test(slug) ? slug : undefined;
|
||||
}
|
||||
243
apps/commandboard-api/src/credshare/store.ts
Normal file
243
apps/commandboard-api/src/credshare/store.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { randomUUID, createHash } from "node:crypto";
|
||||
import type {
|
||||
CredShareUser,
|
||||
CredShareTeam,
|
||||
CredShareMember,
|
||||
CredShareInvite,
|
||||
CredShareVault,
|
||||
CredShareGrant,
|
||||
CredShareSecret,
|
||||
CredShareAuditEvent,
|
||||
CredShareLoginCode,
|
||||
CredShareToken,
|
||||
MemberRole,
|
||||
MemberStatus
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Storage boundary for the credential-sharing server. The router talks only to
|
||||
* this interface, so it can run against an in-memory map (tests / local dev) or
|
||||
* Supabase (production) without changing a line of route logic.
|
||||
*/
|
||||
export interface CredShareStore {
|
||||
// users + keys
|
||||
upsertUserByEmail(email: string): Promise<CredShareUser>;
|
||||
getUserByEmail(email: string): Promise<CredShareUser | undefined>;
|
||||
getUserById(id: string): Promise<CredShareUser | undefined>;
|
||||
setUserPublicKey(userId: string, publicKey: string): Promise<CredShareUser>;
|
||||
|
||||
// auth
|
||||
saveLoginCode(code: CredShareLoginCode): Promise<void>;
|
||||
consumeLoginCode(email: string, codeHash: string, now: Date): Promise<boolean>;
|
||||
saveToken(token: CredShareToken): Promise<void>;
|
||||
getUserIdForToken(tokenHash: string): Promise<string | undefined>;
|
||||
deleteToken(tokenHash: string): Promise<void>;
|
||||
|
||||
// teams + members
|
||||
createTeam(input: { slug: string; name: string; createdBy: string }): Promise<CredShareTeam>;
|
||||
getTeamBySlug(slug: string): Promise<CredShareTeam | undefined>;
|
||||
listTeamsForUser(userId: string): Promise<CredShareTeam[]>;
|
||||
addMember(input: Omit<CredShareMember, "id" | "createdAt">): Promise<CredShareMember>;
|
||||
getMember(teamId: string, userId: string): Promise<CredShareMember | undefined>;
|
||||
getMemberByEmail(teamId: string, email: string): Promise<CredShareMember | undefined>;
|
||||
listMembers(teamId: string): Promise<CredShareMember[]>;
|
||||
updateMember(
|
||||
teamId: string,
|
||||
email: string,
|
||||
patch: Partial<Pick<CredShareMember, "userId" | "status" | "joinedAt" | "role">>
|
||||
): Promise<CredShareMember | undefined>;
|
||||
|
||||
// invites
|
||||
createInvite(input: Omit<CredShareInvite, "id" | "createdAt" | "acceptedAt">): Promise<CredShareInvite>;
|
||||
getInviteByTokenHash(tokenHash: string): Promise<CredShareInvite | undefined>;
|
||||
markInviteAccepted(id: string, at: string): Promise<void>;
|
||||
|
||||
// vaults + grants + secrets
|
||||
createVault(input: { teamId: string; name: string; createdBy: string }): Promise<CredShareVault>;
|
||||
getVault(teamId: string, name: string): Promise<CredShareVault | undefined>;
|
||||
getVaultById(vaultId: string): Promise<CredShareVault | undefined>;
|
||||
listVaults(teamId: string): Promise<CredShareVault[]>;
|
||||
upsertGrant(grant: CredShareGrant): Promise<void>;
|
||||
getGrant(vaultId: string, userId: string): Promise<CredShareGrant | undefined>;
|
||||
listGrants(vaultId: string): Promise<CredShareGrant[]>;
|
||||
listSecrets(vaultId: string): Promise<CredShareSecret[]>;
|
||||
putSecret(secret: CredShareSecret): Promise<void>;
|
||||
deleteSecret(vaultId: string, name: string): Promise<void>;
|
||||
|
||||
// audit
|
||||
appendAudit(event: Omit<CredShareAuditEvent, "id" | "createdAt">): Promise<CredShareAuditEvent>;
|
||||
listAudit(vaultId: string): Promise<CredShareAuditEvent[]>;
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** In-memory store — used by tests and single-process local dev. */
|
||||
export function createMemoryCredShareStore(): CredShareStore {
|
||||
const users = new Map<string, CredShareUser>(); // by id
|
||||
const usersByEmail = new Map<string, string>(); // email -> id
|
||||
const loginCodes = new Map<string, CredShareLoginCode>(); // email -> code
|
||||
const tokens = new Map<string, CredShareToken>(); // tokenHash -> token
|
||||
const teams = new Map<string, CredShareTeam>(); // by id
|
||||
const teamsBySlug = new Map<string, string>(); // slug -> id
|
||||
const members: CredShareMember[] = [];
|
||||
const invites = new Map<string, CredShareInvite>(); // by id
|
||||
const vaults = new Map<string, CredShareVault>(); // by id
|
||||
const grants = new Map<string, CredShareGrant>(); // `${vaultId}:${userId}`
|
||||
const secrets = new Map<string, CredShareSecret>(); // `${vaultId}:${name}`
|
||||
const audit: CredShareAuditEvent[] = [];
|
||||
|
||||
const iso = () => new Date().toISOString();
|
||||
|
||||
return {
|
||||
async upsertUserByEmail(email) {
|
||||
const key = normalizeEmail(email);
|
||||
const existingId = usersByEmail.get(key);
|
||||
if (existingId) {
|
||||
return users.get(existingId)!;
|
||||
}
|
||||
const user: CredShareUser = { id: randomUUID(), email: key, publicKey: null, createdAt: iso() };
|
||||
users.set(user.id, user);
|
||||
usersByEmail.set(key, user.id);
|
||||
return user;
|
||||
},
|
||||
async getUserByEmail(email) {
|
||||
const id = usersByEmail.get(normalizeEmail(email));
|
||||
return id ? users.get(id) : undefined;
|
||||
},
|
||||
async getUserById(id) {
|
||||
return users.get(id);
|
||||
},
|
||||
async setUserPublicKey(userId, publicKey) {
|
||||
const user = users.get(userId);
|
||||
if (!user) throw new Error("user not found");
|
||||
user.publicKey = publicKey;
|
||||
return user;
|
||||
},
|
||||
|
||||
async saveLoginCode(code) {
|
||||
loginCodes.set(normalizeEmail(code.email), code);
|
||||
},
|
||||
async consumeLoginCode(email, codeHash, now) {
|
||||
const key = normalizeEmail(email);
|
||||
const code = loginCodes.get(key);
|
||||
if (!code || code.codeHash !== codeHash || new Date(code.expiresAt).getTime() < now.getTime()) {
|
||||
return false;
|
||||
}
|
||||
loginCodes.delete(key);
|
||||
return true;
|
||||
},
|
||||
async saveToken(token) {
|
||||
tokens.set(token.tokenHash, token);
|
||||
},
|
||||
async getUserIdForToken(tokenHash) {
|
||||
const token = tokens.get(tokenHash);
|
||||
if (token) {
|
||||
token.lastUsedAt = iso();
|
||||
}
|
||||
return token?.userId;
|
||||
},
|
||||
async deleteToken(tokenHash) {
|
||||
tokens.delete(tokenHash);
|
||||
},
|
||||
|
||||
async createTeam({ slug, name, createdBy }) {
|
||||
const team: CredShareTeam = { id: randomUUID(), slug, name, createdBy, createdAt: iso() };
|
||||
teams.set(team.id, team);
|
||||
teamsBySlug.set(slug, team.id);
|
||||
return team;
|
||||
},
|
||||
async getTeamBySlug(slug) {
|
||||
const id = teamsBySlug.get(slug);
|
||||
return id ? teams.get(id) : undefined;
|
||||
},
|
||||
async listTeamsForUser(userId) {
|
||||
const teamIds = new Set(members.filter((m) => m.userId === userId && m.status === "active").map((m) => m.teamId));
|
||||
return [...teamIds].map((id) => teams.get(id)!).filter(Boolean);
|
||||
},
|
||||
async addMember(input) {
|
||||
const member: CredShareMember = { ...input, id: randomUUID(), createdAt: iso() };
|
||||
members.push(member);
|
||||
return member;
|
||||
},
|
||||
async getMember(teamId, userId) {
|
||||
return members.find((m) => m.teamId === teamId && m.userId === userId);
|
||||
},
|
||||
async getMemberByEmail(teamId, email) {
|
||||
const key = normalizeEmail(email);
|
||||
return members.find((m) => m.teamId === teamId && m.email === key);
|
||||
},
|
||||
async listMembers(teamId) {
|
||||
return members.filter((m) => m.teamId === teamId);
|
||||
},
|
||||
async updateMember(teamId, email, patch) {
|
||||
const key = normalizeEmail(email);
|
||||
const member = members.find((m) => m.teamId === teamId && m.email === key);
|
||||
if (!member) return undefined;
|
||||
Object.assign(member, patch);
|
||||
return member;
|
||||
},
|
||||
|
||||
async createInvite(input) {
|
||||
const invite: CredShareInvite = { ...input, id: randomUUID(), acceptedAt: null, createdAt: iso() };
|
||||
invites.set(invite.id, invite);
|
||||
return invite;
|
||||
},
|
||||
async getInviteByTokenHash(tokenHash) {
|
||||
return [...invites.values()].find((i) => i.tokenHash === tokenHash);
|
||||
},
|
||||
async markInviteAccepted(id, at) {
|
||||
const invite = invites.get(id);
|
||||
if (invite) invite.acceptedAt = at;
|
||||
},
|
||||
|
||||
async createVault({ teamId, name, createdBy }) {
|
||||
const vault: CredShareVault = { id: randomUUID(), teamId, name, createdBy, createdAt: iso() };
|
||||
vaults.set(vault.id, vault);
|
||||
return vault;
|
||||
},
|
||||
async getVault(teamId, name) {
|
||||
return [...vaults.values()].find((v) => v.teamId === teamId && v.name === name);
|
||||
},
|
||||
async getVaultById(vaultId) {
|
||||
return vaults.get(vaultId);
|
||||
},
|
||||
async listVaults(teamId) {
|
||||
return [...vaults.values()].filter((v) => v.teamId === teamId);
|
||||
},
|
||||
async upsertGrant(grant) {
|
||||
grants.set(`${grant.vaultId}:${grant.userId}`, grant);
|
||||
},
|
||||
async getGrant(vaultId, userId) {
|
||||
return grants.get(`${vaultId}:${userId}`);
|
||||
},
|
||||
async listGrants(vaultId) {
|
||||
return [...grants.values()].filter((g) => g.vaultId === vaultId);
|
||||
},
|
||||
async listSecrets(vaultId) {
|
||||
return [...secrets.values()].filter((s) => s.vaultId === vaultId).sort((a, b) => a.name.localeCompare(b.name));
|
||||
},
|
||||
async putSecret(secret) {
|
||||
secrets.set(`${secret.vaultId}:${secret.name}`, secret);
|
||||
},
|
||||
async deleteSecret(vaultId, name) {
|
||||
secrets.delete(`${vaultId}:${name}`);
|
||||
},
|
||||
|
||||
async appendAudit(event) {
|
||||
const full: CredShareAuditEvent = { ...event, id: randomUUID(), createdAt: iso() };
|
||||
audit.push(full);
|
||||
return full;
|
||||
},
|
||||
async listAudit(vaultId) {
|
||||
return audit.filter((e) => e.vaultId === vaultId).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type { MemberRole, MemberStatus };
|
||||
238
apps/commandboard-api/src/credshare/supabase-store.ts
Normal file
238
apps/commandboard-api/src/credshare/supabase-store.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { createRequire } from "node:module";
|
||||
import type { CredShareStore } from "./store.js";
|
||||
import { normalizeEmail } from "./store.js";
|
||||
import type {
|
||||
CredShareUser,
|
||||
CredShareTeam,
|
||||
CredShareMember,
|
||||
CredShareInvite,
|
||||
CredShareVault,
|
||||
CredShareGrant,
|
||||
CredShareSecret,
|
||||
CredShareAuditEvent,
|
||||
CredShareLoginCode,
|
||||
CredShareToken
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Supabase-backed CredShareStore (production). Uses the SERVICE ROLE key and
|
||||
* enforces authorization in the router, not via RLS — RLS on these tables is
|
||||
* deny-by-default defense-in-depth. Returns `undefined` when unconfigured so
|
||||
* the API falls back to the in-memory store for local dev.
|
||||
*
|
||||
* Table columns are snake_case; this module maps to/from the camelCase domain
|
||||
* types. See supabase/migrations/*_credshare.sql.
|
||||
*/
|
||||
export function createSupabaseCredShareStore(): CredShareStore | undefined {
|
||||
const url = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
|
||||
if (!url || !key) return undefined;
|
||||
|
||||
// Lazy-require so the API runs without @supabase/supabase-js installed when
|
||||
// Supabase isn't configured (the memory store needs no dependency).
|
||||
const require = createRequire(import.meta.url);
|
||||
const { createClient } = require("@supabase/supabase-js") as typeof import("@supabase/supabase-js");
|
||||
const db = createClient(url, key, { auth: { persistSession: false } });
|
||||
|
||||
const T = {
|
||||
users: "credshare_users",
|
||||
loginCodes: "credshare_login_codes",
|
||||
tokens: "credshare_tokens",
|
||||
teams: "credshare_teams",
|
||||
members: "credshare_members",
|
||||
invites: "credshare_invites",
|
||||
vaults: "credshare_vaults",
|
||||
grants: "credshare_vault_grants",
|
||||
secrets: "credshare_secrets",
|
||||
audit: "credshare_audit"
|
||||
} as const;
|
||||
|
||||
const userOut = (r: Record<string, unknown>): CredShareUser => ({ id: r.id as string, email: r.email as string, publicKey: (r.public_key as string) ?? null, createdAt: r.created_at as string });
|
||||
const teamOut = (r: Record<string, unknown>): CredShareTeam => ({ id: r.id as string, slug: r.slug as string, name: r.name as string, createdBy: r.created_by as string, createdAt: r.created_at as string });
|
||||
const memberOut = (r: Record<string, unknown>): CredShareMember => ({ id: r.id as string, teamId: r.team_id as string, userId: (r.user_id as string) ?? null, email: r.email as string, role: r.role as CredShareMember["role"], status: r.status as CredShareMember["status"], invitedBy: (r.invited_by as string) ?? null, createdAt: r.created_at as string, joinedAt: (r.joined_at as string) ?? null });
|
||||
const vaultOut = (r: Record<string, unknown>): CredShareVault => ({ id: r.id as string, teamId: r.team_id as string, name: r.name as string, createdBy: r.created_by as string, createdAt: r.created_at as string });
|
||||
const secretOut = (r: Record<string, unknown>): CredShareSecret => ({ vaultId: r.vault_id as string, name: r.name as string, nonce: r.nonce as string, ciphertext: r.ciphertext as string, fingerprint: r.fingerprint as string, version: r.version as number, updatedBy: r.updated_by as string, updatedAt: r.updated_at as string });
|
||||
const grantOut = (r: Record<string, unknown>): CredShareGrant => ({ vaultId: r.vault_id as string, userId: r.user_id as string, wrappedDek: r.wrapped_dek as string, grantedBy: r.granted_by as string, createdAt: r.created_at as string });
|
||||
const auditOut = (r: Record<string, unknown>): CredShareAuditEvent => ({ id: r.id as string, teamId: (r.team_id as string) ?? null, vaultId: (r.vault_id as string) ?? null, actorUserId: r.actor_user_id as string, action: r.action as string, keyName: (r.key_name as string) ?? null, fingerprint: (r.fingerprint as string) ?? null, createdAt: r.created_at as string });
|
||||
|
||||
async function one<T>(query: PromiseLike<{ data: unknown; error: unknown }>, map: (r: Record<string, unknown>) => T): Promise<T | undefined> {
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return data ? map(data as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
async upsertUserByEmail(email) {
|
||||
const key = normalizeEmail(email);
|
||||
const existing = await one(db.from(T.users).select("*").eq("email", key).maybeSingle(), userOut);
|
||||
if (existing) return existing;
|
||||
const { data, error } = await db.from(T.users).insert({ email: key }).select("*").single();
|
||||
if (error) throw error;
|
||||
return userOut(data);
|
||||
},
|
||||
async getUserByEmail(email) {
|
||||
return one(db.from(T.users).select("*").eq("email", normalizeEmail(email)).maybeSingle(), userOut);
|
||||
},
|
||||
async getUserById(id) {
|
||||
return one(db.from(T.users).select("*").eq("id", id).maybeSingle(), userOut);
|
||||
},
|
||||
async setUserPublicKey(userId, publicKey) {
|
||||
const { data, error } = await db.from(T.users).update({ public_key: publicKey }).eq("id", userId).select("*").single();
|
||||
if (error) throw error;
|
||||
return userOut(data);
|
||||
},
|
||||
|
||||
async saveLoginCode(code: CredShareLoginCode) {
|
||||
await db.from(T.loginCodes).delete().eq("email", code.email);
|
||||
const { error } = await db.from(T.loginCodes).insert({ email: code.email, code_hash: code.codeHash, expires_at: code.expiresAt });
|
||||
if (error) throw error;
|
||||
},
|
||||
async consumeLoginCode(email, codeHash, nowDate) {
|
||||
const { data, error } = await db.from(T.loginCodes).select("*").eq("email", normalizeEmail(email)).eq("code_hash", codeHash).maybeSingle();
|
||||
if (error) throw error;
|
||||
if (!data || new Date(data.expires_at as string).getTime() < nowDate.getTime()) return false;
|
||||
await db.from(T.loginCodes).delete().eq("email", normalizeEmail(email));
|
||||
return true;
|
||||
},
|
||||
async saveToken(token: CredShareToken) {
|
||||
const { error } = await db.from(T.tokens).insert({ token_hash: token.tokenHash, user_id: token.userId });
|
||||
if (error) throw error;
|
||||
},
|
||||
async getUserIdForToken(tokenHash) {
|
||||
const { data, error } = await db.from(T.tokens).select("user_id").eq("token_hash", tokenHash).maybeSingle();
|
||||
if (error) throw error;
|
||||
if (data) await db.from(T.tokens).update({ last_used_at: new Date().toISOString() }).eq("token_hash", tokenHash);
|
||||
return (data?.user_id as string) ?? undefined;
|
||||
},
|
||||
async deleteToken(tokenHash) {
|
||||
await db.from(T.tokens).delete().eq("token_hash", tokenHash);
|
||||
},
|
||||
|
||||
async createTeam({ slug, name, createdBy }) {
|
||||
const { data, error } = await db.from(T.teams).insert({ slug, name, created_by: createdBy }).select("*").single();
|
||||
if (error) throw error;
|
||||
return teamOut(data);
|
||||
},
|
||||
async getTeamBySlug(slug) {
|
||||
return one(db.from(T.teams).select("*").eq("slug", slug).maybeSingle(), teamOut);
|
||||
},
|
||||
async listTeamsForUser(userId) {
|
||||
const { data, error } = await db.from(T.members).select("team_id").eq("user_id", userId).eq("status", "active");
|
||||
if (error) throw error;
|
||||
const ids = (data ?? []).map((r) => r.team_id as string);
|
||||
if (ids.length === 0) return [];
|
||||
const { data: teams, error: teamErr } = await db.from(T.teams).select("*").in("id", ids);
|
||||
if (teamErr) throw teamErr;
|
||||
return (teams ?? []).map(teamOut);
|
||||
},
|
||||
async addMember(input) {
|
||||
const { data, error } = await db
|
||||
.from(T.members)
|
||||
.insert({ team_id: input.teamId, user_id: input.userId, email: input.email, role: input.role, status: input.status, invited_by: input.invitedBy, joined_at: input.joinedAt })
|
||||
.select("*")
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return memberOut(data);
|
||||
},
|
||||
async getMember(teamId, userId) {
|
||||
return one(db.from(T.members).select("*").eq("team_id", teamId).eq("user_id", userId).maybeSingle(), memberOut);
|
||||
},
|
||||
async getMemberByEmail(teamId, email) {
|
||||
return one(db.from(T.members).select("*").eq("team_id", teamId).eq("email", normalizeEmail(email)).maybeSingle(), memberOut);
|
||||
},
|
||||
async listMembers(teamId) {
|
||||
const { data, error } = await db.from(T.members).select("*").eq("team_id", teamId).order("created_at");
|
||||
if (error) throw error;
|
||||
return (data ?? []).map(memberOut);
|
||||
},
|
||||
async updateMember(teamId, email, patch) {
|
||||
const update: Record<string, unknown> = {};
|
||||
if (patch.userId !== undefined) update.user_id = patch.userId;
|
||||
if (patch.status !== undefined) update.status = patch.status;
|
||||
if (patch.joinedAt !== undefined) update.joined_at = patch.joinedAt;
|
||||
if (patch.role !== undefined) update.role = patch.role;
|
||||
return one(db.from(T.members).update(update).eq("team_id", teamId).eq("email", normalizeEmail(email)).select("*").maybeSingle(), memberOut);
|
||||
},
|
||||
|
||||
async createInvite(input) {
|
||||
const { data, error } = await db
|
||||
.from(T.invites)
|
||||
.insert({ team_id: input.teamId, email: input.email, role: input.role, token_hash: input.tokenHash, created_by: input.createdBy, expires_at: input.expiresAt })
|
||||
.select("*")
|
||||
.single();
|
||||
if (error) throw error;
|
||||
const r = data as Record<string, unknown>;
|
||||
return { id: r.id as string, teamId: r.team_id as string, email: r.email as string, role: r.role as CredShareInvite["role"], tokenHash: r.token_hash as string, createdBy: r.created_by as string, expiresAt: r.expires_at as string, acceptedAt: (r.accepted_at as string) ?? null, createdAt: r.created_at as string };
|
||||
},
|
||||
async getInviteByTokenHash(tokenHash) {
|
||||
const { data, error } = await db.from(T.invites).select("*").eq("token_hash", tokenHash).maybeSingle();
|
||||
if (error) throw error;
|
||||
if (!data) return undefined;
|
||||
const r = data as Record<string, unknown>;
|
||||
return { id: r.id as string, teamId: r.team_id as string, email: r.email as string, role: r.role as CredShareInvite["role"], tokenHash: r.token_hash as string, createdBy: r.created_by as string, expiresAt: r.expires_at as string, acceptedAt: (r.accepted_at as string) ?? null, createdAt: r.created_at as string };
|
||||
},
|
||||
async markInviteAccepted(id, at) {
|
||||
await db.from(T.invites).update({ accepted_at: at }).eq("id", id);
|
||||
},
|
||||
|
||||
async createVault({ teamId, name, createdBy }) {
|
||||
const { data, error } = await db.from(T.vaults).insert({ team_id: teamId, name, created_by: createdBy }).select("*").single();
|
||||
if (error) throw error;
|
||||
return vaultOut(data);
|
||||
},
|
||||
async getVault(teamId, name) {
|
||||
return one(db.from(T.vaults).select("*").eq("team_id", teamId).eq("name", name).maybeSingle(), vaultOut);
|
||||
},
|
||||
async getVaultById(vaultId) {
|
||||
return one(db.from(T.vaults).select("*").eq("id", vaultId).maybeSingle(), vaultOut);
|
||||
},
|
||||
async listVaults(teamId) {
|
||||
const { data, error } = await db.from(T.vaults).select("*").eq("team_id", teamId).order("name");
|
||||
if (error) throw error;
|
||||
return (data ?? []).map(vaultOut);
|
||||
},
|
||||
async upsertGrant(grant) {
|
||||
const { error } = await db
|
||||
.from(T.grants)
|
||||
.upsert({ vault_id: grant.vaultId, user_id: grant.userId, wrapped_dek: grant.wrappedDek, granted_by: grant.grantedBy }, { onConflict: "vault_id,user_id" });
|
||||
if (error) throw error;
|
||||
},
|
||||
async getGrant(vaultId, userId) {
|
||||
return one(db.from(T.grants).select("*").eq("vault_id", vaultId).eq("user_id", userId).maybeSingle(), grantOut);
|
||||
},
|
||||
async listGrants(vaultId) {
|
||||
const { data, error } = await db.from(T.grants).select("*").eq("vault_id", vaultId);
|
||||
if (error) throw error;
|
||||
return (data ?? []).map(grantOut);
|
||||
},
|
||||
async listSecrets(vaultId) {
|
||||
const { data, error } = await db.from(T.secrets).select("*").eq("vault_id", vaultId).order("name");
|
||||
if (error) throw error;
|
||||
return (data ?? []).map(secretOut);
|
||||
},
|
||||
async putSecret(secret) {
|
||||
const { error } = await db
|
||||
.from(T.secrets)
|
||||
.upsert({ vault_id: secret.vaultId, name: secret.name, nonce: secret.nonce, ciphertext: secret.ciphertext, fingerprint: secret.fingerprint, version: secret.version, updated_by: secret.updatedBy, updated_at: secret.updatedAt }, { onConflict: "vault_id,name" });
|
||||
if (error) throw error;
|
||||
},
|
||||
async deleteSecret(vaultId, name) {
|
||||
await db.from(T.secrets).delete().eq("vault_id", vaultId).eq("name", name);
|
||||
},
|
||||
|
||||
async appendAudit(event) {
|
||||
const { data, error } = await db
|
||||
.from(T.audit)
|
||||
.insert({ team_id: event.teamId, vault_id: event.vaultId, actor_user_id: event.actorUserId, action: event.action, key_name: event.keyName, fingerprint: event.fingerprint })
|
||||
.select("*")
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return auditOut(data);
|
||||
},
|
||||
async listAudit(vaultId) {
|
||||
const { data, error } = await db.from(T.audit).select("*").eq("vault_id", vaultId).order("created_at", { ascending: false });
|
||||
if (error) throw error;
|
||||
return (data ?? []).map(auditOut);
|
||||
}
|
||||
};
|
||||
}
|
||||
107
apps/commandboard-api/src/credshare/types.ts
Normal file
107
apps/commandboard-api/src/credshare/types.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* Server-side types for the LogicSRC team credential-sharing API.
|
||||
*
|
||||
* The server is a ZERO-KNOWLEDGE relay for secret VALUES: it stores member
|
||||
* public keys, per-member wrapped vault keys (sealed to those public keys), and
|
||||
* secret ciphertext + nonces. It never receives or stores a plaintext secret or
|
||||
* a vault data-encryption key.
|
||||
*/
|
||||
|
||||
export type MemberRole = "owner" | "admin" | "member";
|
||||
export type MemberStatus = "active" | "invited";
|
||||
|
||||
export interface CredShareUser {
|
||||
id: string;
|
||||
email: string;
|
||||
/** X25519 identity public key (base64), or null until the member uploads one. */
|
||||
publicKey: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CredShareTeam {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CredShareMember {
|
||||
id: string;
|
||||
teamId: string;
|
||||
userId: string | null;
|
||||
email: string;
|
||||
role: MemberRole;
|
||||
status: MemberStatus;
|
||||
invitedBy: string | null;
|
||||
createdAt: string;
|
||||
joinedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CredShareInvite {
|
||||
id: string;
|
||||
teamId: string;
|
||||
email: string;
|
||||
role: MemberRole;
|
||||
tokenHash: string;
|
||||
createdBy: string;
|
||||
expiresAt: string;
|
||||
acceptedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CredShareVault {
|
||||
id: string;
|
||||
teamId: string;
|
||||
name: string;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** A vault DEK sealed to one member's public key. Server can't open it. */
|
||||
export interface CredShareGrant {
|
||||
vaultId: string;
|
||||
userId: string;
|
||||
wrappedDek: string;
|
||||
grantedBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** One secret, encrypted under the vault DEK. `fingerprint` is a salted hash for diffing. */
|
||||
export interface CredShareSecret {
|
||||
vaultId: string;
|
||||
name: string;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
fingerprint: string;
|
||||
version: number;
|
||||
updatedBy: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CredShareAuditEvent {
|
||||
id: string;
|
||||
teamId: string | null;
|
||||
vaultId: string | null;
|
||||
actorUserId: string;
|
||||
action: string;
|
||||
keyName: string | null;
|
||||
fingerprint: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** A pending email login code (the code itself is stored hashed). */
|
||||
export interface CredShareLoginCode {
|
||||
email: string;
|
||||
codeHash: string;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** An issued API token (stored hashed). */
|
||||
export interface CredShareToken {
|
||||
tokenHash: string;
|
||||
userId: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
|
@ -11,6 +11,10 @@ import { listSocialAccountProviders, socialAccountsPlugin } from "@logicsrc/plug
|
|||
import { uGigPlugin } from "@logicsrc/plugin-ugig";
|
||||
import { schemas, validate } from "@logicsrc/validators";
|
||||
import { buildAgentMailService, mailIdentity } from "./agentmail.js";
|
||||
import { createCredShareApi, type CredShareRequest } from "./credshare/router.js";
|
||||
import { createMemoryCredShareStore } from "./credshare/store.js";
|
||||
import { createSupabaseCredShareStore } from "./credshare/supabase-store.js";
|
||||
import { createResendEmailSender } from "./credshare/email.js";
|
||||
|
||||
const registry = createPluginRegistry([coinPayPlugin, uGigPlugin, sh1ptPlugin, c0mputePlugin, feedDiscoveryPlugin, socialAccountsPlugin, emailAccountsPlugin, agentMailPlugin]);
|
||||
|
||||
|
|
@ -57,6 +61,42 @@ const c0mputeWorkers = [
|
|||
{ id: "worker_pool_1", region: "us-west", status: "preview", capacity: "wip" }
|
||||
];
|
||||
|
||||
// Credential-sharing API: Supabase-backed when SUPABASE_URL + service key are
|
||||
// present, else an in-process memory store (local dev / tests). Zero-knowledge:
|
||||
// the server only ever relays ciphertext, wrapped keys, and public keys.
|
||||
const credShareApi = createCredShareApi({
|
||||
store: createSupabaseCredShareStore() ?? createMemoryCredShareStore(),
|
||||
email: createResendEmailSender(),
|
||||
webBaseUrl: process.env.LOGICSRC_WEB_URL || "https://logicsrc.com"
|
||||
});
|
||||
|
||||
const CREDSHARE_PREFIX = "/api/credshare";
|
||||
|
||||
async function handleCredShare(request: IncomingMessage, response: ServerResponse, url: URL) {
|
||||
const method = request.method ?? "GET";
|
||||
let body: unknown;
|
||||
if (method === "POST" || method === "PUT" || method === "PATCH") {
|
||||
try {
|
||||
body = await readJson(request);
|
||||
} catch {
|
||||
json(response, 400, { error: "Invalid JSON body" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const authHeader = request.headers["authorization"];
|
||||
const header = Array.isArray(authHeader) ? authHeader[0] : authHeader;
|
||||
const token = header?.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : undefined;
|
||||
const req: CredShareRequest = {
|
||||
method,
|
||||
path: url.pathname.slice(CREDSHARE_PREFIX.length) || "/",
|
||||
query: url.searchParams,
|
||||
body,
|
||||
token
|
||||
};
|
||||
const result = await credShareApi.handle(req);
|
||||
json(response, result.status, result.body);
|
||||
}
|
||||
|
||||
class InvalidJsonBodyError extends Error {
|
||||
constructor() {
|
||||
super("Invalid JSON body");
|
||||
|
|
@ -85,7 +125,7 @@ async function route(request: IncomingMessage, response: ServerResponse) {
|
|||
json(response, 200, {
|
||||
ok: true,
|
||||
service: "commandboard-api",
|
||||
endpoints: ["/health", "/api/boards", "/api/tasks", "/api/plugins", "/api/schemas", "/api/accounts/providers", "/api/accounts", "/api/social/providers", "/api/email/providers", "/api/feeds/discover", "/api/feeds/providers", "/api/plugins/agentmail/mailboxes", "/api/plugins/agentmail/mailboxes/:mailbox/messages", "/api/plugins/agentmail/mailboxes/:mailbox/messages/:uid", "/api/plugins/agentmail/search", "/api/plugins/agentmail/messages", "/rss/discover/:keyword.xml", "/opml/discover/:keyword.xml", "/atom/discover/:keyword.xml", "/json-feed/discover/:keyword.json"]
|
||||
endpoints: ["/health", "/api/boards", "/api/tasks", "/api/plugins", "/api/schemas", "/api/accounts/providers", "/api/accounts", "/api/social/providers", "/api/email/providers", "/api/feeds/discover", "/api/feeds/providers", "/api/credshare/auth/request", "/api/credshare/auth/verify", "/api/credshare/keys", "/api/credshare/teams", "/api/credshare/teams/:slug/members", "/api/credshare/teams/:slug/invites", "/api/credshare/teams/:slug/vaults", "/api/credshare/invites/accept", "/api/credshare/vaults/:id/secrets", "/api/credshare/vaults/:id/grant", "/api/credshare/vaults/:id/grants", "/api/credshare/vaults/:id/audit", "/api/plugins/agentmail/mailboxes", "/api/plugins/agentmail/mailboxes/:mailbox/messages", "/api/plugins/agentmail/mailboxes/:mailbox/messages/:uid", "/api/plugins/agentmail/search", "/api/plugins/agentmail/messages", "/rss/discover/:keyword.xml", "/opml/discover/:keyword.xml", "/atom/discover/:keyword.xml", "/json-feed/discover/:keyword.json"]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -220,6 +260,11 @@ async function route(request: IncomingMessage, response: ServerResponse) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === CREDSHARE_PREFIX || url.pathname.startsWith(`${CREDSHARE_PREFIX}/`)) {
|
||||
await handleCredShare(request, response, url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/plugins/agentmail" || url.pathname.startsWith("/api/plugins/agentmail/")) {
|
||||
await handleAgentMail(request, response, url);
|
||||
return;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue