diff --git a/.env.example b/.env.example index 6ce789a..c5b0586 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,15 @@ SH1PT_WEBHOOK_SECRET= # webhook authenticates callers by this secret instead). Generate with: # openssl rand -hex 32 BLOG_WEBHOOK_SECRET= + +# --- Team credential sharing (commandboard-api /api/credshare) --- +# Production storage. Without these, the API uses an in-memory store (dev/tests). +SUPABASE_URL= +SUPABASE_SERVICE_ROLE_KEY= +# Email transport for login codes + team invites. Without RESEND_API_KEY the API +# returns codes/tokens in its responses (local dev only — never in production). +RESEND_API_KEY= +CREDSHARE_EMAIL_FROM=LogicSRC +LOGICSRC_WEB_URL=https://logicsrc.com +# Web page → API base (public). Falls back to https://commandboard.run. +NEXT_PUBLIC_COMMANDBOARD_API_URL= diff --git a/apps/commandboard-api/package.json b/apps/commandboard-api/package.json index 0f31698..c20607c 100644 --- a/apps/commandboard-api/package.json +++ b/apps/commandboard-api/package.json @@ -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", diff --git a/apps/commandboard-api/src/credshare/email.ts b/apps/commandboard-api/src/credshare/email.ts new file mode 100644 index 0000000..9bac084 --- /dev/null +++ b/apps/commandboard-api/src/credshare/email.ts @@ -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 "; + const webBaseUrl = process.env.LOGICSRC_WEB_URL || options.webBaseUrl; + + async function send(to: string, subject: string, html: string, text: string): Promise { + 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", + `

Your LogicSRC login code is:

${code}

It expires in 10 minutes. If you didn't request this, ignore this email.

`, + `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`, + `

${invitedByEmail} invited you to share credentials on the ${teamName} (${teamSlug}) team.

+

Accept in the CLI:

+
logicsrc login --email ${email}
+logicsrc teams accept ${token}
+

…or accept on the web. Secrets stay end-to-end encrypted — the server never sees them.

`, + `${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}` + ); + } + }; +} diff --git a/apps/commandboard-api/src/credshare/integration.test.ts b/apps/commandboard-api/src/credshare/integration.test.ts new file mode 100644 index 0000000..2c42543 --- /dev/null +++ b/apps/commandboard-api/src/credshare/integration.test.ts @@ -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((resolve) => server.listen(0, resolve)); + apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + async function readBody(request: IncomingMessage): Promise { + 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 = {}; + 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(); + }); +}); diff --git a/apps/commandboard-api/src/credshare/router.test.ts b/apps/commandboard-api/src/credshare/router.test.ts new file mode 100644 index 0000000..abb034b --- /dev/null +++ b/apps/commandboard-api/src/credshare/router.test.ts @@ -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, email: string): Promise { + 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 } = {}): CredShareRequest { + return { + method, + path, + query: new URLSearchParams(opts.query ?? {}), + body: opts.body, + token: opts.token + }; +} + +describe("credshare API", () => { + let app: ReturnType; + 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); + }); +}); diff --git a/apps/commandboard-api/src/credshare/router.ts b/apps/commandboard-api/src/credshare/router.ts new file mode 100644 index 0000000..5fd9de6 --- /dev/null +++ b/apps/commandboard-api/src/credshare/router.ts @@ -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; + sendInvite(input: { email: string; token: string; teamName: string; teamSlug: string; invitedByEmail: string }): Promise; +} + +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 = { 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 { + 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 { + if (!req.token) return undefined; + return store.getUserIdForToken(hashToken(req.token)); + } + + type MemberGuard = + | { ok: true; team: Awaited> & object; member: CredShareMember } + | { ok: false; response: CredShareResponse }; + + async function requireMember(teamSlug: string, userId: string): Promise { + 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 { + 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; +} diff --git a/apps/commandboard-api/src/credshare/store.ts b/apps/commandboard-api/src/credshare/store.ts new file mode 100644 index 0000000..8209ec5 --- /dev/null +++ b/apps/commandboard-api/src/credshare/store.ts @@ -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; + getUserByEmail(email: string): Promise; + getUserById(id: string): Promise; + setUserPublicKey(userId: string, publicKey: string): Promise; + + // auth + saveLoginCode(code: CredShareLoginCode): Promise; + consumeLoginCode(email: string, codeHash: string, now: Date): Promise; + saveToken(token: CredShareToken): Promise; + getUserIdForToken(tokenHash: string): Promise; + deleteToken(tokenHash: string): Promise; + + // teams + members + createTeam(input: { slug: string; name: string; createdBy: string }): Promise; + getTeamBySlug(slug: string): Promise; + listTeamsForUser(userId: string): Promise; + addMember(input: Omit): Promise; + getMember(teamId: string, userId: string): Promise; + getMemberByEmail(teamId: string, email: string): Promise; + listMembers(teamId: string): Promise; + updateMember( + teamId: string, + email: string, + patch: Partial> + ): Promise; + + // invites + createInvite(input: Omit): Promise; + getInviteByTokenHash(tokenHash: string): Promise; + markInviteAccepted(id: string, at: string): Promise; + + // vaults + grants + secrets + createVault(input: { teamId: string; name: string; createdBy: string }): Promise; + getVault(teamId: string, name: string): Promise; + getVaultById(vaultId: string): Promise; + listVaults(teamId: string): Promise; + upsertGrant(grant: CredShareGrant): Promise; + getGrant(vaultId: string, userId: string): Promise; + listGrants(vaultId: string): Promise; + listSecrets(vaultId: string): Promise; + putSecret(secret: CredShareSecret): Promise; + deleteSecret(vaultId: string, name: string): Promise; + + // audit + appendAudit(event: Omit): Promise; + listAudit(vaultId: string): Promise; +} + +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(); // by id + const usersByEmail = new Map(); // email -> id + const loginCodes = new Map(); // email -> code + const tokens = new Map(); // tokenHash -> token + const teams = new Map(); // by id + const teamsBySlug = new Map(); // slug -> id + const members: CredShareMember[] = []; + const invites = new Map(); // by id + const vaults = new Map(); // by id + const grants = new Map(); // `${vaultId}:${userId}` + const secrets = new Map(); // `${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 }; diff --git a/apps/commandboard-api/src/credshare/supabase-store.ts b/apps/commandboard-api/src/credshare/supabase-store.ts new file mode 100644 index 0000000..bd42497 --- /dev/null +++ b/apps/commandboard-api/src/credshare/supabase-store.ts @@ -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): 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): 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): 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): 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): 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): 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): 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(query: PromiseLike<{ data: unknown; error: unknown }>, map: (r: Record) => T): Promise { + const { data, error } = await query; + if (error) throw error; + return data ? map(data as Record) : 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 = {}; + 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; + 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; + 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); + } + }; +} diff --git a/apps/commandboard-api/src/credshare/types.ts b/apps/commandboard-api/src/credshare/types.ts new file mode 100644 index 0000000..f36350a --- /dev/null +++ b/apps/commandboard-api/src/credshare/types.ts @@ -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; +} diff --git a/apps/commandboard-api/src/index.ts b/apps/commandboard-api/src/index.ts index 809c798..435ca90 100644 --- a/apps/commandboard-api/src/index.ts +++ b/apps/commandboard-api/src/index.ts @@ -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; diff --git a/apps/logicsrc-web/src/app/teams/accept/page.tsx b/apps/logicsrc-web/src/app/teams/accept/page.tsx new file mode 100644 index 0000000..64136cd --- /dev/null +++ b/apps/logicsrc-web/src/app/teams/accept/page.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from "react"; +import type { Metadata } from "next"; +import { SiteShell } from "@/components/site-shell"; +import { TeamsClient } from "@/components/teams-client"; + +export const metadata: Metadata = { + title: "Accept invite · LogicSRC Teams", + description: "Accept a LogicSRC credential-sharing team invite.", + robots: { index: false }, +}; + +// Invite emails link here as /teams/accept?token=… — log in, then the token is +// auto-accepted by the client. +export default async function AcceptInvitePage({ + searchParams, +}: { + searchParams: Promise<{ token?: string }>; +}): Promise { + const { token } = await searchParams; + return ( + + + + ); +} diff --git a/apps/logicsrc-web/src/app/teams/page.tsx b/apps/logicsrc-web/src/app/teams/page.tsx new file mode 100644 index 0000000..33d575b --- /dev/null +++ b/apps/logicsrc-web/src/app/teams/page.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; +import type { Metadata } from "next"; +import { SiteShell } from "@/components/site-shell"; +import { TeamsClient } from "@/components/teams-client"; + +export const metadata: Metadata = { + title: "Teams · Credential Sharing · LogicSRC", + description: + "Manage credential-sharing teams and invites. Share secrets with teammates by email — end-to-end encrypted, so the server never sees plaintext. Decryption happens only in the logicsrc CLI.", + alternates: { canonical: "/teams" }, +}; + +export default function TeamsPage(): ReactNode { + return ( + + + + ); +} diff --git a/apps/logicsrc-web/src/components/site-shell.tsx b/apps/logicsrc-web/src/components/site-shell.tsx index 348df8a..d880b37 100644 --- a/apps/logicsrc-web/src/components/site-shell.tsx +++ b/apps/logicsrc-web/src/components/site-shell.tsx @@ -8,6 +8,7 @@ const NAV: Array<{ href: string; label: string; external?: boolean }> = [ { href: "/agent-swarm", label: "Soon" }, { href: "/agentbyte", label: "AgentByte" }, { href: "/credential-sharing", label: "Credentials" }, + { href: "/teams", label: "Teams" }, { href: "/#cli", label: "CLI" }, { href: "/docs", label: "Docs" }, { href: "/blog", label: "Blog" }, diff --git a/apps/logicsrc-web/src/components/teams-client.tsx b/apps/logicsrc-web/src/components/teams-client.tsx new file mode 100644 index 0000000..5b2d39c --- /dev/null +++ b/apps/logicsrc-web/src/components/teams-client.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +/** + * Team credential-sharing management UI. The browser holds NO private key, so it + * never decrypts secrets — it manages membership, invites, and shows vault + * metadata (names, secret counts, who has access). Actual secret values are only + * ever decrypted in the `logicsrc` CLI on a device that holds the identity key. + */ + +const API_BASE = (process.env.NEXT_PUBLIC_COMMANDBOARD_API_URL ?? "https://commandboard.run").replace(/\/$/, ""); +const TOKEN_KEY = "logicsrc.credshare.token"; +const EMAIL_KEY = "logicsrc.credshare.email"; + +interface Team { + slug: string; + name: string; +} +interface Member { + email: string; + role: string; + status: string; + hasPublicKey: boolean; +} +interface Vault { + id: string; + name: string; + hasAccess: boolean; + secretCount: number; +} + +async function api(path: string, init: RequestInit & { token?: string } = {}): Promise { + const headers: Record = { accept: "application/json", ...(init.headers as Record) }; + if (init.body) headers["content-type"] = "application/json"; + if (init.token) headers["authorization"] = `Bearer ${init.token}`; + const res = await fetch(`${API_BASE}/api/credshare${path}`, { ...init, headers }); + const text = await res.text(); + const parsed = text ? JSON.parse(text) : undefined; + if (!res.ok) throw new Error((parsed && parsed.error) || `${res.status} ${res.statusText}`); + return parsed as T; +} + +export function TeamsClient({ initialToken }: { initialToken?: string }): React.ReactElement { + const [token, setToken] = useState(null); + const [email, setEmail] = useState(""); + const [codeSent, setCodeSent] = useState(false); + const [code, setCode] = useState(""); + const [me, setMe] = useState<{ email: string; publicKey: string | null } | null>(null); + const [teams, setTeams] = useState([]); + const [active, setActive] = useState(null); + const [members, setMembers] = useState([]); + const [vaults, setVaults] = useState([]); + const [inviteEmail, setInviteEmail] = useState(""); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + const saved = window.localStorage.getItem(TOKEN_KEY); + if (saved) setToken(saved); + setEmail(window.localStorage.getItem(EMAIL_KEY) ?? ""); + }, []); + + const refreshTeams = useCallback(async (tok: string) => { + const data = await api<{ user: { email: string; publicKey: string | null }; teams: Team[] }>("/me", { token: tok }); + setMe(data.user); + setTeams(data.teams); + setActive((cur) => cur ?? data.teams[0]?.slug ?? null); + }, []); + + useEffect(() => { + if (!token) return; + refreshTeams(token).catch((e) => setError(String(e.message ?? e))); + }, [token, refreshTeams]); + + const loadTeam = useCallback( + async (slug: string, tok: string) => { + const [m, v] = await Promise.all([ + api<{ members: Member[] }>(`/teams/${encodeURIComponent(slug)}/members`, { token: tok }), + api<{ vaults: Vault[] }>(`/teams/${encodeURIComponent(slug)}/vaults`, { token: tok }) + ]); + setMembers(m.members); + setVaults(v.vaults); + }, + [] + ); + + useEffect(() => { + if (token && active) loadTeam(active, token).catch((e) => setError(String(e.message ?? e))); + }, [token, active, loadTeam]); + + // Auto-accept an invite passed via ?token= once the user is logged in. + useEffect(() => { + if (!initialToken || !token) return; + (async () => { + try { + setBusy(true); + const res = await api<{ team?: Team }>("/invites/accept", { method: "POST", token, body: JSON.stringify({ token: initialToken }) }); + setStatus(`Joined ${res.team?.slug ?? "the team"}. Grant + pull secrets from the CLI.`); + await refreshTeams(token); + if (res.team) setActive(res.team.slug); + } catch (e) { + setError(String((e as Error).message ?? e)); + } finally { + setBusy(false); + } + })(); + }, [initialToken, token, refreshTeams]); + + async function requestCode() { + setError(null); + setBusy(true); + try { + const res = await api<{ emailSent: boolean; devCode?: string }>("/auth/request", { method: "POST", body: JSON.stringify({ email }) }); + setCodeSent(true); + window.localStorage.setItem(EMAIL_KEY, email); + setStatus(res.emailSent ? `Code sent to ${email}.` : `Dev mode — your code is ${res.devCode}.`); + if (res.devCode) setCode(res.devCode); + } catch (e) { + setError(String((e as Error).message ?? e)); + } finally { + setBusy(false); + } + } + + async function verifyCode() { + setError(null); + setBusy(true); + try { + const res = await api<{ token: string }>("/auth/verify", { method: "POST", body: JSON.stringify({ email, code }) }); + window.localStorage.setItem(TOKEN_KEY, res.token); + setToken(res.token); + setCodeSent(false); + setCode(""); + setStatus("Logged in."); + } catch (e) { + setError(String((e as Error).message ?? e)); + } finally { + setBusy(false); + } + } + + async function createTeam() { + const slug = window.prompt("New team slug (lowercase, dashes):"); + if (!slug || !token) return; + try { + await api("/teams", { method: "POST", token, body: JSON.stringify({ slug }) }); + await refreshTeams(token); + setActive(slug); + } catch (e) { + setError(String((e as Error).message ?? e)); + } + } + + async function invite() { + if (!inviteEmail || !active || !token) return; + setBusy(true); + setError(null); + try { + const res = await api<{ emailSent: boolean; token?: string }>(`/teams/${encodeURIComponent(active)}/invites`, { method: "POST", token, body: JSON.stringify({ email: inviteEmail }) }); + setStatus(res.emailSent ? `Invited ${inviteEmail}.` : `Invited ${inviteEmail}. Share this accept link: ${window.location.origin}/teams/accept?token=${res.token}`); + setInviteEmail(""); + await loadTeam(active, token); + } catch (e) { + setError(String((e as Error).message ?? e)); + } finally { + setBusy(false); + } + } + + function logout() { + window.localStorage.removeItem(TOKEN_KEY); + setToken(null); + setMe(null); + setTeams([]); + setActive(null); + setStatus("Logged out."); + } + + const notice = ( + <> + {error &&

⚠ {error}

} + {status && !error &&

{status}

} + + ); + + if (!token) { + return ( +
+
+

Team credential sharing

+

Log in by email to manage teams and invites. Secrets stay end-to-end encrypted — decrypt them with the logicsrc CLI, never here.

+
+ {notice} + {!codeSent ? ( +
+ setEmail(e.target.value)} style={inputStyle} /> + +
+ ) : ( +
+ setCode(e.target.value)} style={inputStyle} /> + +
+ )} +
+ ); + } + + return ( +
+
+

Your teams

+ + {me?.email} {me && !me.publicKey && "· ⚠ no CLI key yet (run logicsrc login)"} · log out + +
+ {notice} + +
+ {teams.map((t) => ( + + ))} + +
+ + {active && ( + <> +

Members

+ + + + + + {members.map((m) => ( + + + + + ))} + +
EmailRoleStatusCLI key
{m.email}{m.role}{m.status}{m.hasPublicKey ? "✓" : "—"}
+ +
+ setInviteEmail(e.target.value)} style={inputStyle} /> + +
+ +

Vaults

+ {vaults.length === 0 ? ( +

No vaults yet. Create one from the CLI: logicsrc teams push {active} prod

+ ) : ( + + + + {vaults.map((v) => ( + + + + + ))} + +
VaultSecretsYour access
{v.name}{v.secretCount}{v.hasAccess ? "✓ granted" : "— ask a member to grant you"}
+ )} +

+ Pull secrets on your machine: logicsrc teams pull {active} <vault> — values are decrypted locally with your key. The server (and this page) only ever see ciphertext. +

+ + )} +
+ ); +} + +const inputStyle: React.CSSProperties = { padding: "0.6rem 0.8rem", borderRadius: "0.5rem", border: "1px solid var(--border, #333)", background: "transparent", color: "inherit", minWidth: "16rem", flex: 1 }; +const buttonStyle: React.CSSProperties = { padding: "0.6rem 1rem", borderRadius: "0.5rem", border: "1px solid var(--border, #333)", background: "var(--accent, #5b7cfa)", color: "#fff", cursor: "pointer" }; +const chipStyle: React.CSSProperties = { padding: "0.4rem 0.8rem", borderRadius: "999px", border: "1px solid var(--border, #333)", background: "transparent", color: "inherit", cursor: "pointer" }; +const chipActive: React.CSSProperties = { background: "var(--accent, #5b7cfa)", color: "#fff", borderColor: "transparent" }; +const tableStyle: React.CSSProperties = { width: "100%", borderCollapse: "collapse", marginTop: "0.5rem", fontSize: "0.92rem" }; +const thStyle: React.CSSProperties = { textAlign: "left", padding: "0.4rem 0.6rem", borderBottom: "1px solid var(--border, #333)", opacity: 0.7, fontWeight: 600 }; +const tdStyle: React.CSSProperties = { padding: "0.4rem 0.6rem", borderBottom: "1px solid var(--border, #222)" }; diff --git a/docs/credential-sharing.md b/docs/credential-sharing.md index 1de4658..4fc9051 100644 --- a/docs/credential-sharing.md +++ b/docs/credential-sharing.md @@ -152,3 +152,66 @@ provider.audit() ``` The adapter boundary lets tools such as a PWA, TUI, CI workflow, or external CLI consume the same open standard without making LogicSRC depend on any specific product. + +## Team Sharing (end-to-end encrypted) + +The `team` provider adds a fifth endpoint type — a hosted, **end-to-end-encrypted** +team vault — so you can share credentials with teammates by email instead of +passing `.env` files over chat. It is addressed as `team:/` +(`endpoint.project` = team slug, `endpoint.config` = vault name). + +### Trust model + +The server (`commandboard-api`, routes under `/api/credshare`) is a **zero-knowledge +relay for secret values**. It stores only: + +- member identity **public keys** (X25519), +- the vault **data-encryption key (DEK) sealed to each member's public key** + (`crypto_box_seal`), one wrapped copy per member, +- secret **ciphertext + nonce** (`crypto_secretbox`), plus a salted fingerprint + for redacted diffs. + +Plaintext secret values and the raw DEK never leave a member's machine. Granting a +teammate access = an existing member unwraps the DEK with their private key and +re-wraps (seals) it to the new member's public key. The private key lives only in +`~/.logicsrc/identity.json` (mode 0600) and is never uploaded. + +### CLI + +```bash +# One-time: log in by email (registers this device's identity key). +logicsrc login --email you@example.com + +# Owner: create a team, push a local .env into an encrypted vault, invite people. +logicsrc teams create acme --name "Acme Inc" +logicsrc teams push acme prod --env .env # encrypt + upload +logicsrc teams invite acme teammate@example.com # emails an accept link + +# Teammate: accept, then get granted, then pull + decrypt locally. +logicsrc login --email teammate@example.com +logicsrc teams accept +# …an existing member runs: logicsrc teams grant acme prod teammate@example.com +logicsrc teams pull acme prod --env .env # download + decrypt + +# Inspect / manage +logicsrc teams list +logicsrc teams members acme +logicsrc teams vaults acme +``` + +Because `team` is a normal provider, the generic sync surface works too — e.g. +`logicsrc credentials plan --from env --from-path .env --to team --to-project acme +--to-config prod`, then `diff`, `sync`, `audit`, and `rollback` behave exactly as +with the other providers. + +### Server + web + +- Server storage is behind a `CredShareStore` interface: an in-memory store for + local dev/tests, and a Supabase-backed store (`SUPABASE_URL` + + `SUPABASE_SERVICE_ROLE_KEY`) for production. Migration: + `supabase/migrations/*_credshare.sql` (deny-by-default RLS). +- Email (login codes + invites) uses Resend when `RESEND_API_KEY` is set; without + it, codes/tokens are returned in the API response for local use. +- `logicsrc.com/teams` is a management surface only: log in by email, view + teams/members/vaults and invite/accept. The browser holds no private key, so it + never decrypts — decryption happens only in the CLI. diff --git a/package-lock.json b/package-lock.json index 0471c01..5214762 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,11 +33,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", diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 73ad771..9468305 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -9,6 +9,20 @@ import { listSocialAccountProviders } from "@logicsrc/plugin-social-accounts"; import { renderArcadeList, renderPluginStatus, renderTui, runArcadeSession, type TaskSnapshot } from "@logicsrc/tui"; import { assertSchemaKind, parseDocument, validate } from "@logicsrc/validators"; import { getConfigValue, readConfig, setConfigValue, writeConfig } from "./config.js"; +import { + loginAction, + logoutAction, + whoamiAction, + teamsCreateAction, + teamsListAction, + teamsInviteAction, + teamsAcceptAction, + teamsMembersAction, + teamsVaultsAction, + teamsGrantAction, + teamsPushAction, + teamsPullAction +} from "./teams.js"; import { boards, tasks } from "./fixtures.js"; import { print, type OutputFormat } from "./format.js"; import { parsePositiveInteger } from "./numeric-options.js"; @@ -64,21 +78,27 @@ program.action(async (options) => { program .command("login") - .option("--did ", "CoinPay DID") - .option("--oauth ", "OAuth provider") - .description("Start a login flow.") - .action((options) => { - const mode = options.did ? `CoinPay DID ${options.did}` : options.oauth ? `${options.oauth} OAuth` : "browser/device"; - console.log(`Login flow ready: ${mode}`); - console.log("Token storage target: $HOME/.logicsrc/auth.json"); + .option("--email ", "Email to log in with (LogicSRC team credential sharing)") + .option("--code ", "Login code (skip the interactive prompt)") + .option("--did ", "CoinPay DID (legacy)") + .option("--oauth ", "OAuth provider (legacy)") + .description("Log in by email for team credential sharing (registers your device identity key).") + .action(async (options) => { + if (!options.email && (options.did || options.oauth)) { + const mode = options.did ? `CoinPay DID ${options.did}` : `${options.oauth} OAuth`; + console.log(`Login flow ready: ${mode}`); + console.log("Token storage target: $HOME/.logicsrc/identity.json"); + return; + } + await loginAction({ email: options.email, code: options.code }); }); -program.command("logout").description("Clear local auth token.").action(() => { - console.log("Logged out. Local auth token would be removed from $HOME/.logicsrc/auth.json."); +program.command("logout").description("Clear local auth token (keeps your identity key).").action(async () => { + await logoutAction(); }); -program.command("whoami").description("Show current DID and account context.").action(() => { - print({ did: process.env.COMMANDBOARD_DID || "anthony.coinpay", api_url: process.env.COMMANDBOARD_API_URL || "http://localhost:4010" }, "table"); +program.command("whoami").option("--format ", "table, json, or markdown", "table").description("Show current login + teams.").action(async (options) => { + await whoamiAction(options.format as OutputFormat); }); program @@ -524,6 +544,79 @@ credentials print(credentialEngine().exportCredentialAudit(options.run), options.format as OutputFormat); }); +const teams = program.command("teams").description("Share credentials with teammates by email — end-to-end encrypted team vaults."); + +teams + .command("create") + .argument("", "Team slug (lowercase letters, numbers, dashes)") + .option("--name ", "Display name") + .option("--format ", "table, json, or markdown", "table") + .description("Create a team you own.") + .action((slug, options) => teamsCreateAction(slug, { name: options.name, format: options.format as OutputFormat })); + +teams + .command("list") + .option("--format ", "table, json, or markdown", "table") + .description("List teams you belong to.") + .action((options) => teamsListAction(options.format as OutputFormat)); + +teams + .command("invite") + .argument("", "Team slug") + .argument("", "Teammate email") + .option("--role ", "owner | admin | member", "member") + .option("--format ", "table, json, or markdown", "table") + .description("Invite a teammate by email.") + .action((slug, email, options) => teamsInviteAction(slug, email, { role: options.role, format: options.format as OutputFormat })); + +teams + .command("accept") + .argument("", "Invite token from your email") + .option("--format ", "table, json, or markdown", "table") + .description("Accept a team invite.") + .action((token, options) => teamsAcceptAction(token, options.format as OutputFormat)); + +teams + .command("members") + .argument("", "Team slug") + .option("--format ", "table, json, or markdown", "table") + .description("List team members and their status.") + .action((slug, options) => teamsMembersAction(slug, options.format as OutputFormat)); + +teams + .command("vaults") + .argument("", "Team slug") + .option("--format ", "table, json, or markdown", "table") + .description("List a team's credential vaults.") + .action((slug, options) => teamsVaultsAction(slug, options.format as OutputFormat)); + +teams + .command("grant") + .argument("", "Team slug") + .argument("", "Vault name") + .argument("", "Teammate email to grant vault access") + .option("--format ", "table, json, or markdown", "table") + .description("Grant a member decryption access to a vault (re-wraps the vault key to their key).") + .action((slug, vault, email, options) => teamsGrantAction(slug, vault, email, options.format as OutputFormat)); + +teams + .command("push") + .argument("", "Team slug") + .argument("", "Vault name") + .option("--env ", "Source .env file", ".env") + .option("--format ", "table, json, or markdown", "table") + .description("Encrypt and push a local .env into a team vault.") + .action((slug, vault, options) => teamsPushAction(slug, vault, { env: options.env, format: options.format as OutputFormat })); + +teams + .command("pull") + .argument("", "Team slug") + .argument("", "Vault name") + .option("--env ", "Destination .env file", ".env") + .option("--format ", "table, json, or markdown", "table") + .description("Pull a team vault and decrypt it into a local .env.") + .action((slug, vault, options) => teamsPullAction(slug, vault, { env: options.env, format: options.format as OutputFormat })); + const accounts = program.command("accounts").description("Manage connected social and email accounts."); accounts diff --git a/packages/cli/src/teams.ts b/packages/cli/src/teams.ts new file mode 100644 index 0000000..469408e --- /dev/null +++ b/packages/cli/src/teams.ts @@ -0,0 +1,207 @@ +import { createInterface } from "node:readline/promises"; +import { + TeamClient, + TeamApiError, + loadOrCreateIdentity, + readIdentity, + updateIdentity, + requireAuth, + defaultApiUrl, + createCredentialEngine, + unwrapVaultKey, + wrapVaultKey, + type CredentialEndpoint +} from "@logicsrc/plugin-credential-sharing"; +import { print, type OutputFormat } from "./format.js"; + +/** + * `logicsrc login` + `logicsrc teams …` — the team credential-sharing surface. + * Secrets are end-to-end encrypted: the server (commandboard-api /api/credshare) + * only ever sees ciphertext and per-member wrapped vault keys. + */ + +function authedClient(): { client: TeamClient; identity: ReturnType } { + const identity = requireAuth(); + const client = new TeamClient({ apiUrl: identity.apiUrl || defaultApiUrl(), token: identity.apiToken }); + return { client, identity }; +} + +async function prompt(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + try { + return (await rl.question(question)).trim(); + } finally { + rl.close(); + } +} + +async function resolveVaultId(client: TeamClient, slug: string, vault: string): Promise { + const { vaults } = await client.listVaults(slug); + const found = vaults.find((v) => v.name === vault); + if (!found) throw new Error(`Vault "${vault}" not found in team "${slug}". Create it by pushing to it.`); + return found.id; +} + +export async function loginAction(options: { email?: string; code?: string }): Promise { + const identity = await loadOrCreateIdentity(); + const email = options.email ?? (await prompt("Email: ")); + if (!email) throw new Error("An email is required: logicsrc login --email you@example.com"); + const client = new TeamClient({ apiUrl: identity.apiUrl || defaultApiUrl() }); + + const requested = await client.requestLoginCode(email); + let code = options.code; + if (requested.devCode) { + // No email transport configured server-side (local dev) — the code is returned. + console.error(`(dev) login code: ${requested.devCode}`); + code = code ?? requested.devCode; + } + if (!code) code = await prompt(`Enter the 6-digit code sent to ${email}: `); + + const verified = await client.verifyLoginCode(email, code); + client.setToken(verified.token); + await client.uploadPublicKey(identity.keys.publicKey); + await updateIdentity({ email: verified.user.email, userId: verified.user.id, apiToken: verified.token, apiUrl: identity.apiUrl || defaultApiUrl() }); + + console.error(`Logged in as ${verified.user.email}. Identity key registered.`); + print({ email: verified.user.email, userId: verified.user.id, apiUrl: identity.apiUrl || defaultApiUrl() }, "table"); +} + +export async function logoutAction(): Promise { + const identity = readIdentity(); + if (identity?.apiToken) { + try { + const client = new TeamClient({ apiUrl: identity.apiUrl || defaultApiUrl(), token: identity.apiToken }); + await client.logout(); + } catch { + // best effort — token may already be gone + } + } + await updateIdentity({ apiToken: undefined, email: undefined, userId: undefined }); + console.error("Logged out. Local identity key retained (delete ~/.logicsrc/identity.json to remove it)."); +} + +export async function whoamiAction(format: OutputFormat): Promise { + const identity = readIdentity(); + if (!identity?.apiToken) { + print({ loggedIn: false, apiUrl: defaultApiUrl(), hint: "Run: logicsrc login --email you@example.com" }, format); + return; + } + const { client } = authedClient(); + const me = await client.me(); + print({ loggedIn: true, email: me.user.email, apiUrl: identity.apiUrl, publicKey: me.user.publicKey, teams: me.teams.map((t) => t.slug) }, format); +} + +export async function teamsCreateAction(slug: string, options: { name?: string; format: OutputFormat }): Promise { + const { client } = authedClient(); + const { team } = await client.createTeam(slug, options.name); + console.error(`Created team ${team.slug}. Invite teammates: logicsrc teams invite ${team.slug} them@example.com`); + print(team, options.format); +} + +export async function teamsListAction(format: OutputFormat): Promise { + const { client } = authedClient(); + const { teams } = await client.listTeams(); + print(teams.length ? teams.map((t) => ({ slug: t.slug, name: t.name })) : [{ note: "No teams yet. Create one: logicsrc teams create " }], format); +} + +export async function teamsInviteAction(slug: string, email: string, options: { role?: string; format: OutputFormat }): Promise { + const { client } = authedClient(); + const role = options.role as "owner" | "admin" | "member" | undefined; + const result = await client.invite(slug, email, role); + if (result.emailSent) { + console.error(`Invited ${email} to ${slug}. An email is on the way.`); + print({ invited: email, team: slug, role: result.invite.role, emailSent: true }, options.format); + } else { + console.error(`Invited ${email} to ${slug}. No email transport configured — share this accept command with them:`); + console.error(` logicsrc login --email ${email} && logicsrc teams accept ${result.token}`); + print({ invited: email, team: slug, role: result.invite.role, token: result.token }, options.format); + } +} + +export async function teamsAcceptAction(token: string, format: OutputFormat): Promise { + const { client } = authedClient(); + const result = await client.acceptInvite(token); + console.error(`Joined ${result.team?.slug ?? "team"}. Ask a member to grant you a vault, then: logicsrc teams pull `); + print({ joined: result.team?.slug ?? null }, format); +} + +export async function teamsMembersAction(slug: string, format: OutputFormat): Promise { + const { client } = authedClient(); + const { members } = await client.listMembers(slug); + print( + members.map((m) => ({ email: m.email, role: m.role, status: m.status, hasKey: m.hasPublicKey })), + format + ); +} + +export async function teamsVaultsAction(slug: string, format: OutputFormat): Promise { + const { client } = authedClient(); + const { vaults } = await client.listVaults(slug); + print( + vaults.length ? vaults.map((v) => ({ vault: v.name, secrets: v.secretCount, youHaveAccess: v.hasAccess })) : [{ note: "No vaults yet. Push to create one: logicsrc teams push " }], + format + ); +} + +export async function teamsGrantAction(slug: string, vault: string, email: string, format: OutputFormat): Promise { + const { client, identity } = authedClient(); + const vaultId = await resolveVaultId(client, slug, vault); + + // Unwrap the vault DEK with our own key, then re-wrap it to the target member. + let myWrapped: string; + try { + myWrapped = (await client.getMyGrant(vaultId)).wrappedDek; + } catch (error) { + if (error instanceof TeamApiError && error.status === 403) { + throw new Error(`You don't have access to ${slug}/${vault} yourself, so you can't grant it. Ask an existing member.`); + } + throw error; + } + const dek = await unwrapVaultKey(myWrapped, identity.keys); + + const target = await client.lookupUser(email); + if (!target.userId) throw new Error(`${email} has not logged in yet. Ask them to run: logicsrc login --email ${email}`); + if (!target.publicKey) throw new Error(`${email} has not registered a key yet. Ask them to run: logicsrc login --email ${email}`); + + await client.putGrant(vaultId, email, await wrapVaultKey(dek, target.publicKey)); + console.error(`Granted ${email} access to ${slug}/${vault}. They can now: logicsrc teams pull ${slug} ${vault}`); + print({ granted: email, team: slug, vault }, format); +} + +function teamEndpoint(slug: string, vault: string): CredentialEndpoint { + return { provider: "team", project: slug, config: vault }; +} + +export async function teamsPushAction(slug: string, vault: string, options: { env: string; format: OutputFormat }): Promise { + requireAuth(); + const engine = createCredentialEngine(); + const from: CredentialEndpoint = { provider: "env", path: options.env }; + const plan = await engine.createCredentialSyncPlan({ from, to: teamEndpoint(slug, vault) }); + if (plan.changes.length === 0) { + console.error(`${slug}/${vault} is already up to date with ${options.env}.`); + print({ team: slug, vault, changes: 0 }, options.format); + return; + } + const approval = engine.approveCredentialSync(plan.id); + const run = await engine.runCredentialSync(plan.id, { dryRun: false, approval }); + const applied = run.results.filter((r) => r.applied).length; + console.error(`Pushed ${applied} secret(s) from ${options.env} to ${slug}/${vault} (end-to-end encrypted).`); + print({ team: slug, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format); +} + +export async function teamsPullAction(slug: string, vault: string, options: { env: string; format: OutputFormat }): Promise { + requireAuth(); + const engine = createCredentialEngine(); + const to: CredentialEndpoint = { provider: "env", path: options.env }; + const plan = await engine.createCredentialSyncPlan({ from: teamEndpoint(slug, vault), to }); + if (plan.changes.length === 0) { + console.error(`${options.env} is already up to date with ${slug}/${vault}.`); + print({ team: slug, vault, changes: 0 }, options.format); + return; + } + const approval = engine.approveCredentialSync(plan.id); + const run = await engine.runCredentialSync(plan.id, { dryRun: false, approval }); + const applied = run.results.filter((r) => r.applied).length; + console.error(`Pulled ${applied} secret(s) from ${slug}/${vault} into ${options.env}.`); + print({ team: slug, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format); +} diff --git a/plugins/credential-sharing/src/client.ts b/plugins/credential-sharing/src/client.ts new file mode 100644 index 0000000..f77e200 --- /dev/null +++ b/plugins/credential-sharing/src/client.ts @@ -0,0 +1,168 @@ +/** + * 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 { + 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; + hasPublicKey: boolean; + hasAccess: boolean; +} + +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: string }; emailSent: boolean; token?: string }>( + "POST", + `/teams/${encodeURIComponent(slug)}/invites`, + { email, role } + ); + } + 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 }); + } + 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); +} diff --git a/plugins/credential-sharing/src/crypto.test.ts b/plugins/credential-sharing/src/crypto.test.ts new file mode 100644 index 0000000..b2b5508 --- /dev/null +++ b/plugins/credential-sharing/src/crypto.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { + generateIdentityKeyPair, + generateVaultKey, + wrapVaultKey, + unwrapVaultKey, + encryptValue, + decryptValue, + publicKeyForSecret +} from "./crypto.js"; + +describe("credential-sharing crypto (E2E)", () => { + it("derives the public key from the secret key", async () => { + const kp = await generateIdentityKeyPair(); + expect(await publicKeyForSecret(kp.secretKey)).toBe(kp.publicKey); + }); + + it("wraps a DEK to a member and only that member can unwrap it", async () => { + const alice = await generateIdentityKeyPair(); + const mallory = await generateIdentityKeyPair(); + const dek = await generateVaultKey(); + + const wrapped = await wrapVaultKey(dek, alice.publicKey); + expect(await unwrapVaultKey(wrapped, alice)).toBe(dek); + + // A different keypair cannot open a box sealed to Alice. + await expect(unwrapVaultKey(wrapped, mallory)).rejects.toThrow(); + }); + + it("round-trips a secret value under the vault DEK", async () => { + const dek = await generateVaultKey(); + const sealed = await encryptValue("super-secret-token", dek); + expect(sealed.ciphertext).not.toContain("super-secret-token"); + expect(await decryptValue(sealed, dek)).toBe("super-secret-token"); + }); + + it("re-wrapping a DEK to a new member grants them decryption (the grant flow)", async () => { + const owner = await generateIdentityKeyPair(); + const invitee = await generateIdentityKeyPair(); + const dek = await generateVaultKey(); + const sealed = await encryptValue("DATABASE_URL=postgres://…", dek); + + // Owner wraps to self, stores ciphertext. Later the owner grants the invitee: + const ownerWrapped = await wrapVaultKey(dek, owner.publicKey); + const ownerDek = await unwrapVaultKey(ownerWrapped, owner); + const inviteeWrapped = await wrapVaultKey(ownerDek, invitee.publicKey); + + // Invitee unwraps with their own key and decrypts the same ciphertext. + const inviteeDek = await unwrapVaultKey(inviteeWrapped, invitee); + expect(await decryptValue(sealed, inviteeDek)).toBe("DATABASE_URL=postgres://…"); + }); + + it("produces distinct nonces/ciphertext for the same value", async () => { + const dek = await generateVaultKey(); + const a = await encryptValue("same", dek); + const b = await encryptValue("same", dek); + expect(a.nonce).not.toBe(b.nonce); + expect(a.ciphertext).not.toBe(b.ciphertext); + }); +}); diff --git a/plugins/credential-sharing/src/crypto.ts b/plugins/credential-sharing/src/crypto.ts new file mode 100644 index 0000000..78fd9de --- /dev/null +++ b/plugins/credential-sharing/src/crypto.ts @@ -0,0 +1,135 @@ +/** + * End-to-end credential-sharing crypto for LogicSRC team vaults. + * + * Trust model (1Password-style): the server only ever stores ciphertext and + * member public keys. Secret values are encrypted client-side and only decrypt + * on a device that holds the member's private key. + * + * Scheme + * ------ + * - Each MEMBER has an X25519 identity keypair. The public key is uploaded to + * the server; the secret key never leaves the member's machine. + * - Each VAULT has a symmetric data-encryption key (the DEK, 32 bytes). + * - Each SECRET VALUE is encrypted with the vault DEK using crypto_secretbox + * (XSalsa20-Poly1305) under a fresh random nonce. + * - The vault DEK is WRAPPED to each member with crypto_box_seal (anonymous + * sealed box) against that member's public key. The server stores one wrapped + * DEK per member; only that member can open it. The server never sees the DEK. + * + * Granting a new member access = an existing member unwraps the DEK with their + * secret key and re-wraps (seals) it to the new member's public key. The DEK + * plaintext exists only in memory on an already-authorized member's machine. + */ + +type Sodium = { + ready: Promise; + base64_variants: { ORIGINAL: number }; + crypto_secretbox_NONCEBYTES: number; + crypto_secretbox_KEYBYTES: number; + from_base64(input: string, variant: number): Uint8Array; + to_base64(input: Uint8Array, variant: number): string; + from_string(input: string): Uint8Array; + to_string(input: Uint8Array): string; + randombytes_buf(length: number): Uint8Array; + crypto_box_keypair(): { publicKey: Uint8Array; privateKey: Uint8Array }; + crypto_box_seal(message: Uint8Array, publicKey: Uint8Array): Uint8Array; + crypto_box_seal_open(ciphertext: Uint8Array, publicKey: Uint8Array, privateKey: Uint8Array): Uint8Array; + crypto_secretbox_easy(message: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array; + crypto_secretbox_open_easy(ciphertext: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array; + crypto_scalarmult_base(privateKey: Uint8Array): Uint8Array; +}; + +let sodiumPromise: Promise | undefined; + +async function loadSodium(): Promise { + if (!sodiumPromise) { + sodiumPromise = (async () => { + // libsodium-wrappers ships a broken ESM entry (its .mjs imports a sibling + // libsodium.mjs that isn't published). Load the self-contained CJS build + // via createRequire so this works under both native ESM and vitest. + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + const mod = require("libsodium-wrappers") as { default?: Sodium } & Sodium; + const sodium: Sodium = mod.default ?? mod; + await sodium.ready; + return sodium; + })(); + } + return sodiumPromise; +} + +function b64(sodium: Sodium, bytes: Uint8Array): string { + return sodium.to_base64(bytes, sodium.base64_variants.ORIGINAL); +} + +function unb64(sodium: Sodium, value: string): Uint8Array { + return sodium.from_base64(value, sodium.base64_variants.ORIGINAL); +} + +/** A member identity keypair, base64-encoded for storage/transport. */ +export interface IdentityKeyPair { + publicKey: string; + secretKey: string; +} + +/** A single secret value encrypted under a vault DEK. */ +export interface SealedValue { + nonce: string; + ciphertext: string; +} + +/** Generate a fresh X25519 identity keypair for a member. */ +export async function generateIdentityKeyPair(): Promise { + const sodium = await loadSodium(); + const { publicKey, privateKey } = sodium.crypto_box_keypair(); + return { publicKey: b64(sodium, publicKey), secretKey: b64(sodium, privateKey) }; +} + +/** Derive the public key for a stored secret key (used to validate an identity file). */ +export async function publicKeyForSecret(secretKeyB64: string): Promise { + const sodium = await loadSodium(); + return b64(sodium, sodium.crypto_scalarmult_base(unb64(sodium, secretKeyB64))); +} + +/** Generate a new random vault data-encryption key (raw bytes, base64). */ +export async function generateVaultKey(): Promise { + const sodium = await loadSodium(); + return b64(sodium, sodium.randombytes_buf(sodium.crypto_secretbox_KEYBYTES)); +} + +/** Seal (wrap) a vault DEK to a member's public key. Only their secret key opens it. */ +export async function wrapVaultKey(dekB64: string, memberPublicKeyB64: string): Promise { + const sodium = await loadSodium(); + const sealed = sodium.crypto_box_seal(unb64(sodium, dekB64), unb64(sodium, memberPublicKeyB64)); + return b64(sodium, sealed); +} + +/** Open a wrapped vault DEK with the member's own keypair. */ +export async function unwrapVaultKey(wrappedB64: string, identity: IdentityKeyPair): Promise { + const sodium = await loadSodium(); + const dek = sodium.crypto_box_seal_open( + unb64(sodium, wrappedB64), + unb64(sodium, identity.publicKey), + unb64(sodium, identity.secretKey) + ); + return b64(sodium, dek); +} + +/** Encrypt a raw secret value with the vault DEK. */ +export async function encryptValue(value: string, dekB64: string): Promise { + const sodium = await loadSodium(); + const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const ciphertext = sodium.crypto_secretbox_easy(sodium.from_string(value), nonce, unb64(sodium, dekB64)); + return { nonce: b64(sodium, nonce), ciphertext: b64(sodium, ciphertext) }; +} + +/** Decrypt a sealed secret value with the vault DEK. */ +export async function decryptValue(sealed: SealedValue, dekB64: string): Promise { + const sodium = await loadSodium(); + const plain = sodium.crypto_secretbox_open_easy( + unb64(sodium, sealed.ciphertext), + unb64(sodium, sealed.nonce), + unb64(sodium, dekB64) + ); + return sodium.to_string(plain); +} diff --git a/plugins/credential-sharing/src/identity.ts b/plugins/credential-sharing/src/identity.ts new file mode 100644 index 0000000..e3ce6ad --- /dev/null +++ b/plugins/credential-sharing/src/identity.ts @@ -0,0 +1,108 @@ +import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { generateIdentityKeyPair, publicKeyForSecret, type IdentityKeyPair } from "./crypto.js"; + +/** + * Local, machine-bound member identity for team credential sharing. + * + * Stored at `$LOGICSRC_HOME/identity.json` (default `~/.logicsrc/identity.json`), + * mode 0600 — it holds the member's X25519 SECRET key and the server API token. + * The secret key never leaves this file; only the public key is uploaded. + */ +export interface LocalIdentity { + /** Server base URL this identity is registered against. */ + apiUrl: string; + /** The member's email (their team-membership handle). */ + email?: string; + /** Server-assigned user id, once logged in. */ + userId?: string; + /** Opaque bearer token for the credshare API. */ + apiToken?: string; + /** X25519 identity keypair (base64). */ + keys: IdentityKeyPair; + createdAt: string; + updatedAt: string; +} + +export function logicsrcHome(): string { + if (process.env.LOGICSRC_HOME) { + return resolve(process.env.LOGICSRC_HOME); + } + return join(homedir(), ".logicsrc"); +} + +export function identityPath(): string { + return process.env.LOGICSRC_IDENTITY_FILE + ? resolve(process.env.LOGICSRC_IDENTITY_FILE) + : join(logicsrcHome(), "identity.json"); +} + +export function defaultApiUrl(): string { + return process.env.COMMANDBOARD_API_URL || process.env.LOGICSRC_API_URL || "http://localhost:4010"; +} + +function writeSecure(file: string, data: unknown): void { + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); + writeFileSync(file, JSON.stringify(data, null, 2), { mode: 0o600 }); + // Ensure 0600 even if the file already existed with looser perms. + chmodSync(file, 0o600); +} + +export function readIdentity(file = identityPath()): LocalIdentity | undefined { + if (!existsSync(file)) { + return undefined; + } + return JSON.parse(readFileSync(file, "utf8")) as LocalIdentity; +} + +/** + * Load the local identity, creating a fresh keypair on first use. Callers still + * need to `logicsrc login` to attach an email/token, but the keypair exists + * immediately so the public key can be uploaded during login. + */ +export async function loadOrCreateIdentity(file = identityPath()): Promise { + const existing = readIdentity(file); + if (existing?.keys?.secretKey) { + return existing; + } + const now = new Date().toISOString(); + const identity: LocalIdentity = { + apiUrl: defaultApiUrl(), + keys: await generateIdentityKeyPair(), + createdAt: now, + updatedAt: now + }; + writeSecure(file, identity); + return identity; +} + +export function saveIdentity(identity: LocalIdentity, file = identityPath()): void { + writeSecure(file, { ...identity, updatedAt: new Date().toISOString() }); +} + +/** Update fields on the stored identity, creating the keypair if absent. */ +export async function updateIdentity( + patch: Partial>, + file = identityPath() +): Promise { + const current = await loadOrCreateIdentity(file); + const next: LocalIdentity = { ...current, ...patch, updatedAt: new Date().toISOString() }; + writeSecure(file, next); + return next; +} + +/** Require a logged-in identity (token present), or throw with guidance. */ +export function requireAuth(file = identityPath()): LocalIdentity & { apiToken: string; email: string } { + const identity = readIdentity(file); + if (!identity?.apiToken || !identity.email) { + throw new Error('Not logged in. Run "logicsrc login --email you@example.com" first.'); + } + return identity as LocalIdentity & { apiToken: string; email: string }; +} + +/** Sanity-check that a stored identity's public key matches its secret key. */ +export async function verifyIdentityIntegrity(identity: LocalIdentity): Promise { + const derived = await publicKeyForSecret(identity.keys.secretKey); + return derived === identity.keys.publicKey; +} diff --git a/plugins/credential-sharing/src/index.ts b/plugins/credential-sharing/src/index.ts index 9810902..23e585c 100644 --- a/plugins/credential-sharing/src/index.ts +++ b/plugins/credential-sharing/src/index.ts @@ -53,9 +53,44 @@ export { dopplerProvider, railwayProvider, githubSecretsProvider, + teamProvider, parseEnv, applyEnv } from "./providers/index.js"; +export { + TeamClient, + TeamApiError, + type TeamClientOptions, + type RemoteUser, + type RemoteTeam, + type RemoteMember, + type RemoteVault, + type RemoteSecret, + type RemoteGrantRow +} from "./client.js"; +export { + generateIdentityKeyPair, + generateVaultKey, + wrapVaultKey, + unwrapVaultKey, + encryptValue, + decryptValue, + publicKeyForSecret, + type IdentityKeyPair, + type SealedValue +} from "./crypto.js"; +export { + loadOrCreateIdentity, + readIdentity, + saveIdentity, + updateIdentity, + requireAuth, + verifyIdentityIntegrity, + identityPath, + logicsrcHome, + defaultApiUrl, + type LocalIdentity +} from "./identity.js"; export { createFileCredentialStore, createMemoryCredentialStore, diff --git a/plugins/credential-sharing/src/manifest.ts b/plugins/credential-sharing/src/manifest.ts index 8b92d96..ceb1d99 100644 --- a/plugins/credential-sharing/src/manifest.ts +++ b/plugins/credential-sharing/src/manifest.ts @@ -17,6 +17,6 @@ export const credentialSharingManifest: PluginManifest = { "credentials.audit.read", "credentials.export" ], - commands: ["credentials"], - env: ["DOPPLER_TOKEN", "RAILWAY_TOKEN", "GITHUB_TOKEN", "LOGICSRC_CREDENTIAL_HOME"] + commands: ["credentials", "teams"], + env: ["DOPPLER_TOKEN", "RAILWAY_TOKEN", "GITHUB_TOKEN", "LOGICSRC_CREDENTIAL_HOME", "LOGICSRC_HOME", "COMMANDBOARD_API_URL", "LOGICSRC_API_URL"] }; diff --git a/plugins/credential-sharing/src/providers/github-secrets.ts b/plugins/credential-sharing/src/providers/github-secrets.ts index 4df865d..977db43 100644 --- a/plugins/credential-sharing/src/providers/github-secrets.ts +++ b/plugins/credential-sharing/src/providers/github-secrets.ts @@ -45,7 +45,10 @@ interface PublicKey { } async function sealValue(value: string, publicKeyB64: string): Promise { - const sodiumModule = (await import("libsodium-wrappers")) as unknown as { default?: SodiumLike } & SodiumLike; + // libsodium-wrappers' ESM entry is broken; load its self-contained CJS build. + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + const sodiumModule = require("libsodium-wrappers") as { default?: SodiumLike } & SodiumLike; const sodium: SodiumLike = sodiumModule.default ?? sodiumModule; await sodium.ready; const key = sodium.from_base64(publicKeyB64, sodium.base64_variants.ORIGINAL); diff --git a/plugins/credential-sharing/src/providers/index.ts b/plugins/credential-sharing/src/providers/index.ts index 031816c..3c4f3f7 100644 --- a/plugins/credential-sharing/src/providers/index.ts +++ b/plugins/credential-sharing/src/providers/index.ts @@ -3,8 +3,9 @@ import { envProvider } from "./env.js"; import { dopplerProvider } from "./doppler.js"; import { railwayProvider } from "./railway.js"; import { githubSecretsProvider } from "./github-secrets.js"; +import { teamProvider } from "./team.js"; -export const credentialProviders: CredentialProvider[] = [envProvider, dopplerProvider, railwayProvider, githubSecretsProvider]; +export const credentialProviders: CredentialProvider[] = [envProvider, dopplerProvider, railwayProvider, githubSecretsProvider, teamProvider]; export const credentialProviderRegistry: Map = new Map( credentialProviders.map((provider) => [provider.id, provider]) @@ -21,5 +22,5 @@ export function listCredentialProviderManifests(): CredentialProviderManifest[] })); } -export { envProvider, dopplerProvider, railwayProvider, githubSecretsProvider }; +export { envProvider, dopplerProvider, railwayProvider, githubSecretsProvider, teamProvider }; export { parseEnv, applyEnv } from "./env.js"; diff --git a/plugins/credential-sharing/src/providers/team.ts b/plugins/credential-sharing/src/providers/team.ts new file mode 100644 index 0000000..e036710 --- /dev/null +++ b/plugins/credential-sharing/src/providers/team.ts @@ -0,0 +1,160 @@ +import { fingerprintValue } from "../fingerprint.js"; +import { TeamClient, TeamApiError, type RemoteSecret } from "../client.js"; +import { requireAuth, defaultApiUrl } from "../identity.js"; +import { generateVaultKey, wrapVaultKey, unwrapVaultKey, encryptValue, decryptValue } from "../crypto.js"; +import type { LocalIdentity } from "../identity.js"; +import type { + CredentialEndpoint, + CredentialProvider, + CredentialSnapshot, + CredentialValueBag, + CredentialWriteResult +} from "../types.js"; + +/** + * The `team` credential provider — an end-to-end-encrypted team vault addressed + * as `team:/` (endpoint.project = slug, endpoint.config = + * vault). Secret values are encrypted/decrypted on THIS machine with the vault + * DEK; the server only ever sees ciphertext and the DEK sealed to member keys. + * + * Auth + identity come from the local `~/.logicsrc/identity.json` (via + * `logicsrc login`), mirroring how `env` reads files and `github-secrets` reads + * GITHUB_TOKEN — the provider is pure I/O over ambient credentials. + */ + +interface TeamContext { + client: TeamClient; + identity: LocalIdentity & { apiToken: string; email: string }; +} + +function context(): TeamContext { + const identity = requireAuth(); + return { client: new TeamClient({ apiUrl: identity.apiUrl || defaultApiUrl(), token: identity.apiToken }), identity }; +} + +function slugAndVault(endpoint: CredentialEndpoint): { slug: string; vault: string } { + const slug = endpoint.project; + const vault = endpoint.config; + if (!slug || !vault) { + throw new Error('A team endpoint needs a team and vault: team:/ (e.g. team:acme/prod).'); + } + return { slug, vault }; +} + +async function resolveVaultId(ctx: TeamContext, slug: string, vault: string, create: boolean): Promise { + const { vaults } = await ctx.client.listVaults(slug); + const found = vaults.find((v) => v.name === vault); + if (found) return found.id; + if (!create) return undefined; + const created = await ctx.client.createVault(slug, vault); + return created.vault.id; +} + +/** Fetch (or, for a brand-new vault, mint) the vault DEK, decrypting nothing yet. */ +async function acquireDek(ctx: TeamContext, slug: string, vault: string, vaultId: string, allowMint: boolean): Promise { + try { + const { wrappedDek } = await ctx.client.getMyGrant(vaultId); + return unwrapVaultKey(wrappedDek, ctx.identity.keys); + } catch (error) { + if (!(error instanceof TeamApiError) || error.status !== 403) throw error; + // No grant yet. If nobody holds the DEK, this is a fresh vault we can own. + const { grants } = await ctx.client.listGrants(vaultId); + const someoneHasAccess = grants.some((g) => g.hasAccess); + if (someoneHasAccess || !allowMint) { + throw new Error( + `You don't have access to team:${slug}/${vault} yet. Ask a member to run:\n logicsrc teams grant ${slug} ${vault} ${ctx.identity.email}` + ); + } + const dek = await generateVaultKey(); + const wrapped = await wrapVaultKey(dek, ctx.identity.keys.publicKey); + await ctx.client.putGrant(vaultId, ctx.identity.email, wrapped); + return dek; + } +} + +export const teamProvider: CredentialProvider = { + id: "team", + name: "LogicSRC Team Vault", + description: "End-to-end-encrypted team credential vault. Share secrets with teammates by email — the server never sees plaintext.", + status: "available", + authRequirements: ["logicsrc login (identity at ~/.logicsrc/identity.json)"], + capabilities: { readValues: true, readNames: true, write: true, delete: true, rollback: true, audit: true }, + + async inspect(endpoint: CredentialEndpoint): Promise { + const ctx = context(); + const { slug, vault } = slugAndVault(endpoint); + const vaultId = await resolveVaultId(ctx, slug, vault, false); + if (!vaultId) { + return { provider: "team", endpoint, valuesReadable: true, keys: [], inspectedAt: new Date().toISOString() }; + } + const { secrets } = await ctx.client.listSecrets(vaultId); + // Whether we can actually decrypt (i.e. hold a grant) determines valuesReadable. + let valuesReadable = true; + try { + await ctx.client.getMyGrant(vaultId); + } catch { + valuesReadable = false; + } + return { + provider: "team", + endpoint, + valuesReadable, + keys: secrets + .map((s: RemoteSecret) => ({ name: s.name, present: true, fingerprint: s.fingerprint, lastModifiedAt: s.updatedAt })) + .sort((a, b) => a.name.localeCompare(b.name)), + inspectedAt: new Date().toISOString() + }; + }, + + async readValues(endpoint: CredentialEndpoint, keys: string[]): Promise { + const ctx = context(); + const { slug, vault } = slugAndVault(endpoint); + const vaultId = await resolveVaultId(ctx, slug, vault, false); + if (!vaultId) return {}; + const dek = await acquireDek(ctx, slug, vault, vaultId, false); + const { secrets } = await ctx.client.listSecrets(vaultId); + const wanted = new Set(keys); + const bag: CredentialValueBag = {}; + for (const secret of secrets) { + if (!wanted.has(secret.name)) continue; + bag[secret.name] = await decryptValue({ nonce: secret.nonce, ciphertext: secret.ciphertext }, dek); + } + return bag; + }, + + async write(input): Promise { + const ctx = context(); + const { slug, vault } = slugAndVault(input.endpoint); + const upsertNames = Object.keys(input.upserts); + const results: CredentialWriteResult[] = []; + + if (input.dryRun) { + for (const name of upsertNames) results.push({ key: name, applied: false }); + for (const name of input.deletes) results.push({ key: name, applied: false }); + return results; + } + + const vaultId = await resolveVaultId(ctx, slug, vault, true); + if (!vaultId) throw new Error(`Could not resolve or create vault team:${slug}/${vault}.`); + + if (upsertNames.length === 0 && input.deletes.length === 0) return results; + + const dek = upsertNames.length > 0 ? await acquireDek(ctx, slug, vault, vaultId, true) : undefined; + const encrypted: Array<{ name: string; nonce: string; ciphertext: string; fingerprint: string }> = []; + for (const name of upsertNames) { + const sealed = await encryptValue(input.upserts[name], dek!); + encrypted.push({ name, nonce: sealed.nonce, ciphertext: sealed.ciphertext, fingerprint: fingerprintValue(input.upserts[name]) }); + } + + const { applied } = await ctx.client.putSecrets(vaultId, encrypted, input.deletes); + const appliedSet = new Set(applied); + for (const name of upsertNames) results.push({ key: name, applied: appliedSet.has(name) }); + for (const name of input.deletes) results.push({ key: name, applied: appliedSet.has(name) }); + return results; + }, + + async rollback(input): Promise { + // Restoring a pre-image is just re-encrypting prior values under the DEK. + return this.write!({ endpoint: input.endpoint, upserts: input.preImage, deletes: [], dryRun: input.dryRun }); + } +}; diff --git a/supabase/migrations/20260713010000_credshare.sql b/supabase/migrations/20260713010000_credshare.sql new file mode 100644 index 0000000..3b2d539 --- /dev/null +++ b/supabase/migrations/20260713010000_credshare.sql @@ -0,0 +1,134 @@ +-- LogicSRC team credential sharing (end-to-end encrypted). +-- +-- 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 a plaintext secret or a vault +-- data-encryption key. The commandboard-api service uses the SERVICE ROLE key +-- and enforces membership authorization in application code. RLS here is +-- deny-by-default defense-in-depth for any non-service (anon/authenticated) +-- access path. + +create extension if not exists "citext"; + +-- Members (identified by email) and their identity public keys. +create table if not exists credshare_users ( + id uuid primary key default gen_random_uuid(), + email citext not null unique, + public_key text, + created_at timestamptz not null default now() +); + +-- Short-lived email login codes (stored hashed). +create table if not exists credshare_login_codes ( + email citext primary key, + code_hash text not null, + expires_at timestamptz not null, + created_at timestamptz not null default now() +); + +-- Issued API bearer tokens (stored hashed). +create table if not exists credshare_tokens ( + token_hash text primary key, + user_id uuid not null references credshare_users(id) on delete cascade, + created_at timestamptz not null default now(), + last_used_at timestamptz +); +create index if not exists credshare_tokens_user_idx on credshare_tokens(user_id); + +create table if not exists credshare_teams ( + id uuid primary key default gen_random_uuid(), + slug citext not null unique, + name text not null, + created_by uuid not null references credshare_users(id), + created_at timestamptz not null default now() +); + +create table if not exists credshare_members ( + id uuid primary key default gen_random_uuid(), + team_id uuid not null references credshare_teams(id) on delete cascade, + user_id uuid references credshare_users(id) on delete set null, + email citext not null, + role text not null default 'member' check (role in ('owner', 'admin', 'member')), + status text not null default 'invited' check (status in ('active', 'invited')), + invited_by uuid references credshare_users(id), + joined_at timestamptz, + created_at timestamptz not null default now(), + unique (team_id, email) +); +create index if not exists credshare_members_user_idx on credshare_members(user_id) where user_id is not null; +create index if not exists credshare_members_team_idx on credshare_members(team_id); + +create table if not exists credshare_invites ( + id uuid primary key default gen_random_uuid(), + team_id uuid not null references credshare_teams(id) on delete cascade, + email citext not null, + role text not null default 'member' check (role in ('owner', 'admin', 'member')), + token_hash text not null unique, + created_by uuid not null references credshare_users(id), + expires_at timestamptz not null, + accepted_at timestamptz, + created_at timestamptz not null default now() +); +create index if not exists credshare_invites_team_idx on credshare_invites(team_id); + +create table if not exists credshare_vaults ( + id uuid primary key default gen_random_uuid(), + team_id uuid not null references credshare_teams(id) on delete cascade, + name citext not null, + created_by uuid not null references credshare_users(id), + created_at timestamptz not null default now(), + unique (team_id, name) +); + +-- Vault data-encryption key, sealed to each member's public key (one row per member). +create table if not exists credshare_vault_grants ( + id uuid primary key default gen_random_uuid(), + vault_id uuid not null references credshare_vaults(id) on delete cascade, + user_id uuid not null references credshare_users(id) on delete cascade, + wrapped_dek text not null, + granted_by uuid not null references credshare_users(id), + created_at timestamptz not null default now(), + unique (vault_id, user_id) +); + +-- Encrypted secrets. `ciphertext`/`nonce` decrypt only with the vault DEK, which +-- the server never sees. `fingerprint` is a salted hash used for redacted diffs. +create table if not exists credshare_secrets ( + id uuid primary key default gen_random_uuid(), + vault_id uuid not null references credshare_vaults(id) on delete cascade, + name text not null, + nonce text not null, + ciphertext text not null, + fingerprint text not null, + version integer not null default 1, + updated_by uuid not null references credshare_users(id), + updated_at timestamptz not null default now(), + unique (vault_id, name) +); + +create table if not exists credshare_audit ( + id uuid primary key default gen_random_uuid(), + team_id uuid references credshare_teams(id) on delete set null, + vault_id uuid references credshare_vaults(id) on delete set null, + actor_user_id uuid not null references credshare_users(id), + action text not null, + key_name text, + fingerprint text, + created_at timestamptz not null default now() +); +create index if not exists credshare_audit_vault_idx on credshare_audit(vault_id, created_at desc); + +-- Deny-by-default RLS: only the service role (which bypasses RLS) may touch +-- these tables. No anon/authenticated policies are defined, so every non-service +-- request is denied. Confidentiality of secret values comes from E2E encryption, +-- not from RLS. +alter table credshare_users enable row level security; +alter table credshare_login_codes enable row level security; +alter table credshare_tokens enable row level security; +alter table credshare_teams enable row level security; +alter table credshare_members enable row level security; +alter table credshare_invites enable row level security; +alter table credshare_vaults enable row level security; +alter table credshare_vault_grants enable row level security; +alter table credshare_secrets enable row level security; +alter table credshare_audit enable row level security;