feat(pwa): logicsrc credentials app — real auth + Turso, redesigned; retire commandboard-api credshare
Some checks failed
CI / build (push) Has been cancelled
test / test (push) Has been cancelled

Adds apps/pwa: an Express + libSQL/Turso app that is now the home of team
credential sharing, with the moshcode-style auth stack ported and reskinned to
match logicsrc.com (light theme, Inter, green accent).

apps/pwa
- auth: email/password (scrypt), passkeys (WebAuthn), CoinPay OAuth, cookie
  sessions, and lsk_ API keys for the CLI via a loopback OAuth-PKCE flow
  (/cli/authorize + /cli/token). Ported from the moshcode PWA.
- credshare API (/api/credshare/*): teams, members, invites, vaults, sealed
  grants, ciphertext secrets, audit — authed by session OR Bearer lsk_ key.
  Zero-knowledge: only ciphertext + sealed vault keys + public keys stored.
- teams dashboard, accept-invite, and settings (API keys) pages, server-rendered
  in the LogicSRC brand (lib/html.mjs).
- migrations (libSQL) 001_auth + 002_credshare, migrate-on-boot; Turso via
  TURSO_DATABASE_URL / TURSO_AUTH_TOKEN, or a local file db for dev.
- trimmed moshcode-specific approvals/credits/push/deliver.

CLI
- `logicsrc login` now does browser loopback OAuth-PKCE against the app and
  stores an lsk_ token (email-OTP removed); --token for CI. Client repointed.

Distribution
- install.sh (served at logicsrc.com/install.sh) installs the CLI from the
  GitHub repo: tarball -> npm install -> `npm run build:cli` -> logicsrc wrapper.
- root build:cli builds only the CLI's workspace chain (skips web/api/next).

Cleanup
- removed the commandboard-api credshare backend (superseded by the PWA) and its
  Supabase/Turso stores + libsql dep; commandboard-api tests green (40).
- removed the Next.js /teams page (the PWA is the web UI now).

Verified end-to-end: two accounts register on the PWA, mint lsk_ keys, CLI login
uploads identity keys, owner pushes an encrypted .env, teammate invited ->
accepted -> granted -> pulls the exact file. Server stores ciphertext only.
Full workspace build + tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-07-13 14:29:29 +00:00
parent f057589d66
commit 9ba044577f
46 changed files with 2785 additions and 1730 deletions

View file

@ -1,51 +0,0 @@
import type { CredShareEmailSender } from "./router.js";
/**
* Resend-backed email transport for login codes and team invites.
*
* Returns `undefined` when RESEND_API_KEY is not configured, so the API falls
* back to echoing codes/tokens in its responses (fine for local dev; production
* sets the key and never echoes secrets).
*/
export function createResendEmailSender(options: { webBaseUrl: string } = { webBaseUrl: "https://logicsrc.com" }): CredShareEmailSender | undefined {
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) return undefined;
const from = process.env.CREDSHARE_EMAIL_FROM || "LogicSRC <noreply@logicsrc.com>";
const webBaseUrl = process.env.LOGICSRC_WEB_URL || options.webBaseUrl;
async function send(to: string, subject: string, html: string, text: string): Promise<void> {
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
body: JSON.stringify({ from, to, subject, html, text })
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(`Resend send failed: ${response.status} ${body.slice(0, 200)}`);
}
}
return {
async sendLoginCode(email, code) {
await send(
email,
"Your LogicSRC login code",
`<p>Your LogicSRC login code is:</p><p style="font-size:24px;font-weight:bold;letter-spacing:3px">${code}</p><p>It expires in 10 minutes. If you didn't request this, ignore this email.</p>`,
`Your LogicSRC login code is: ${code}\nIt expires in 10 minutes.`
);
},
async sendInvite({ email, token, teamName, teamSlug, invitedByEmail }) {
const acceptUrl = `${webBaseUrl}/teams/accept?token=${encodeURIComponent(token)}`;
await send(
email,
`You're invited to the "${teamName}" credential team on LogicSRC`,
`<p><strong>${invitedByEmail}</strong> invited you to share credentials on the <strong>${teamName}</strong> (<code>${teamSlug}</code>) team.</p>
<p>Accept in the CLI:</p>
<pre>logicsrc login --email ${email}
logicsrc teams accept ${token}</pre>
<p>or <a href="${acceptUrl}">accept on the web</a>. Secrets stay end-to-end encrypted the server never sees them.</p>`,
`${invitedByEmail} invited you to the "${teamName}" (${teamSlug}) credential team on LogicSRC.\n\nAccept in the CLI:\n logicsrc login --email ${email}\n logicsrc teams accept ${token}\n\nOr on the web: ${acceptUrl}`
);
}
};
}

View file

@ -1,132 +0,0 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import type { AddressInfo } from "node:net";
import { createServer, type Server, type IncomingMessage } from "node:http";
import { createCredShareApi, type CredShareRequest } from "./router.js";
import { createMemoryCredShareStore } from "./store.js";
import {
TeamClient,
generateIdentityKeyPair,
generateVaultKey,
wrapVaultKey,
unwrapVaultKey,
encryptValue,
decryptValue,
fingerprintValue
} from "@logicsrc/plugin-credential-sharing";
/**
* True end-to-end test: the real HTTP server + the real TeamClient + real
* libsodium crypto. Alice shares a .env with Bob purely through the server, and
* we assert the server never held plaintext while Bob still recovers the values.
*/
describe("credshare end-to-end (server + client + crypto)", () => {
let server: Server;
let apiUrl: string;
beforeAll(async () => {
// Hermetic server: real router + memory store, no email transport (so the
// dev-code echo path is deterministic and independent of ambient env).
const api = createCredShareApi({ store: createMemoryCredShareStore() });
server = createServer(async (request, response) => {
const url = new URL(request.url ?? "/", "http://localhost");
const method = request.method ?? "GET";
let body: unknown;
if (method === "POST" || method === "PUT" || method === "PATCH") {
body = await readBody(request);
}
const header = request.headers["authorization"];
const token = header?.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : undefined;
const req: CredShareRequest = { method, path: url.pathname.replace(/^\/api\/credshare/, "") || "/", query: url.searchParams, body, token };
const result = await api.handle(req);
response.writeHead(result.status, { "content-type": "application/json" });
response.end(JSON.stringify(result.body));
});
await new Promise<void>((resolve) => server.listen(0, resolve));
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
async function readBody(request: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
const text = Buffer.concat(chunks).toString("utf8");
return text ? JSON.parse(text) : undefined;
}
async function loginClient(email: string) {
const client = new TeamClient({ apiUrl });
const req = await client.requestLoginCode(email);
const verify = await client.verifyLoginCode(email, req.devCode!);
client.setToken(verify.token);
return client;
}
it("shares an env bundle from Alice to Bob without the server ever seeing plaintext", async () => {
const secretEnv = { DATABASE_URL: "postgres://user:pw@host/db", STRIPE_KEY: "sk_live_deadbeef" };
// --- Alice sets up ---
const alice = await loginClient("alice@acme.dev");
const aliceKeys = await generateIdentityKeyPair();
await alice.uploadPublicKey(aliceKeys.publicKey);
await alice.createTeam("acme-e2e", "Acme");
const { vault } = await alice.createVault("acme-e2e", "prod");
// Alice mints the vault DEK, wraps it to herself, and pushes ciphertext.
const dek = await generateVaultKey();
await alice.putGrant(vault.id, "alice@acme.dev", await wrapVaultKey(dek, aliceKeys.publicKey));
const upserts = [] as Array<{ name: string; nonce: string; ciphertext: string; fingerprint: string }>;
for (const [name, value] of Object.entries(secretEnv)) {
const sealed = await encryptValue(value, dek);
upserts.push({ name, ...sealed, fingerprint: fingerprintValue(value) });
}
await alice.putSecrets(vault.id, upserts, []);
// The server must only hold ciphertext — assert no plaintext leaks over the wire.
const stored = await alice.listSecrets(vault.id);
for (const s of stored.secrets) {
expect(s.ciphertext).not.toContain("postgres://");
expect(s.ciphertext).not.toContain("sk_live");
}
// --- Bob joins ---
const bob = await loginClient("bob@acme.dev");
const bobKeys = await generateIdentityKeyPair();
await bob.uploadPublicKey(bobKeys.publicKey);
const invite = await alice.invite("acme-e2e", "bob@acme.dev");
await bob.acceptInvite(invite.token!);
// --- Alice grants Bob: look up Bob's pubkey, re-wrap the DEK to it ---
const bobUser = await alice.lookupUser("bob@acme.dev");
const aliceDek = await unwrapVaultKey((await alice.getMyGrant(vault.id)).wrappedDek, aliceKeys);
await alice.putGrant(vault.id, "bob@acme.dev", await wrapVaultKey(aliceDek, bobUser.publicKey!));
// --- Bob pulls + decrypts on his machine ---
const bobDek = await unwrapVaultKey((await bob.getMyGrant(vault.id)).wrappedDek, bobKeys);
const bobSecrets = await bob.listSecrets(vault.id);
const recovered: Record<string, string> = {};
for (const s of bobSecrets.secrets) {
recovered[s.name] = await decryptValue({ nonce: s.nonce, ciphertext: s.ciphertext }, bobDek);
}
expect(recovered).toEqual(secretEnv);
});
it("denies decryption to a member who has not been granted the DEK", async () => {
const alice = await loginClient("alice2@acme.dev");
const aliceKeys = await generateIdentityKeyPair();
await alice.uploadPublicKey(aliceKeys.publicKey);
await alice.createTeam("acme-e2e-2", "Acme2");
const { vault } = await alice.createVault("acme-e2e-2", "prod");
const dek = await generateVaultKey();
await alice.putGrant(vault.id, "alice2@acme.dev", await wrapVaultKey(dek, aliceKeys.publicKey));
const carol = await loginClient("carol@acme.dev");
await carol.uploadPublicKey((await generateIdentityKeyPair()).publicKey);
const invite = await alice.invite("acme-e2e-2", "carol@acme.dev");
await carol.acceptInvite(invite.token!);
// Carol is a member (can see ciphertext) but has no grant → cannot get a DEK.
await expect(carol.getMyGrant(vault.id)).rejects.toThrow();
});
});

View file

@ -1,134 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
import { createMemoryCredShareStore } from "./store.js";
import { createCredShareApi, type CredShareRequest } from "./router.js";
function api() {
return createCredShareApi({ store: createMemoryCredShareStore() });
}
async function login(app: ReturnType<typeof createCredShareApi>, email: string): Promise<string> {
const req = await app.handle(reqOf("POST", "/auth/request", { body: { email } }));
const code = (req.body as { devCode: string }).devCode;
const verify = await app.handle(reqOf("POST", "/auth/verify", { body: { email, code } }));
return (verify.body as { token: string }).token;
}
function reqOf(method: string, path: string, opts: { body?: unknown; token?: string; query?: Record<string, string> } = {}): CredShareRequest {
return {
method,
path,
query: new URLSearchParams(opts.query ?? {}),
body: opts.body,
token: opts.token
};
}
describe("credshare API", () => {
let app: ReturnType<typeof createCredShareApi>;
beforeEach(() => {
app = api();
});
it("issues a token via the email-code flow and rejects a bad code", async () => {
const request = await app.handle(reqOf("POST", "/auth/request", { body: { email: "Owner@Example.com" } }));
expect(request.status).toBe(200);
const code = (request.body as { devCode: string }).devCode;
expect(code).toMatch(/^\d{6}$/);
const bad = await app.handle(reqOf("POST", "/auth/verify", { body: { email: "owner@example.com", code: "000000" } }));
expect(bad.status).toBe(401);
const good = await app.handle(reqOf("POST", "/auth/verify", { body: { email: "owner@example.com", code } }));
expect(good.status).toBe(200);
expect((good.body as { user: { email: string } }).user.email).toBe("owner@example.com");
});
it("rejects protected routes without a token", async () => {
const res = await app.handle(reqOf("GET", "/teams"));
expect(res.status).toBe(401);
});
it("runs the full invite → accept → push → grant → pull loop", async () => {
const ownerToken = await login(app, "owner@example.com");
const bobToken = await login(app, "bob@example.com");
// Both members upload public keys.
await app.handle(reqOf("POST", "/keys", { token: ownerToken, body: { publicKey: "OWNER_PUBKEY_B64" } }));
await app.handle(reqOf("POST", "/keys", { token: bobToken, body: { publicKey: "BOB_PUBKEY_B64" } }));
// Owner creates a team + vault.
const team = await app.handle(reqOf("POST", "/teams", { token: ownerToken, body: { slug: "acme", name: "Acme" } }));
expect(team.status).toBe(201);
const vaultRes = await app.handle(reqOf("POST", "/teams/acme/vaults", { token: ownerToken, body: { name: "prod" } }));
const vaultId = (vaultRes.body as { vault: { id: string } }).vault.id;
// Owner self-grants (wraps DEK to own key) and pushes ciphertext.
await app.handle(reqOf("POST", `/vaults/${vaultId}/grants`, { token: ownerToken, body: { email: "owner@example.com", wrappedDek: "DEK_SEALED_TO_OWNER" } }));
const push = await app.handle(
reqOf("PUT", `/vaults/${vaultId}/secrets`, {
token: ownerToken,
body: { upserts: [{ name: "DATABASE_URL", nonce: "n1", ciphertext: "c1", fingerprint: "fp1" }] }
})
);
expect(push.status).toBe(200);
// Bob can't access yet — no grant.
const bobEarly = await app.handle(reqOf("GET", `/vaults/${vaultId}/grant`, { token: bobToken }));
expect(bobEarly.status).toBe(403);
// Owner invites Bob; Bob accepts.
const invite = await app.handle(reqOf("POST", "/teams/acme/invites", { token: ownerToken, body: { email: "bob@example.com" } }));
expect(invite.status).toBe(201);
const inviteToken = (invite.body as { token: string }).token;
const accept = await app.handle(reqOf("POST", "/invites/accept", { token: bobToken, body: { token: inviteToken } }));
expect(accept.status).toBe(200);
// Bob is now a member and can read ciphertext, but still needs a grant to decrypt.
const members = await app.handle(reqOf("GET", "/teams/acme/members", { token: bobToken }));
expect((members.body as { members: unknown[] }).members).toHaveLength(2);
// Owner grants Bob (re-wraps DEK to Bob's pubkey).
const grantBob = await app.handle(reqOf("POST", `/vaults/${vaultId}/grants`, { token: ownerToken, body: { email: "bob@example.com", wrappedDek: "DEK_SEALED_TO_BOB" } }));
expect(grantBob.status).toBe(201);
// Bob pulls his wrapped DEK and the ciphertext.
const bobGrant = await app.handle(reqOf("GET", `/vaults/${vaultId}/grant`, { token: bobToken }));
expect((bobGrant.body as { wrappedDek: string }).wrappedDek).toBe("DEK_SEALED_TO_BOB");
const secrets = await app.handle(reqOf("GET", `/vaults/${vaultId}/secrets`, { token: bobToken }));
expect((secrets.body as { secrets: { name: string }[] }).secrets[0].name).toBe("DATABASE_URL");
});
it("stops a non-admin from inviting and enforces invite-email match", async () => {
const ownerToken = await login(app, "owner@example.com");
const bobToken = await login(app, "bob@example.com");
const eveToken = await login(app, "eve@example.com");
await app.handle(reqOf("POST", "/keys", { token: bobToken, body: { publicKey: "BOB" } }));
await app.handle(reqOf("POST", "/teams", { token: ownerToken, body: { slug: "acme", name: "Acme" } }));
// Invite Bob as a plain member; Bob accepts.
const inv = await app.handle(reqOf("POST", "/teams/acme/invites", { token: ownerToken, body: { email: "bob@example.com" } }));
await app.handle(reqOf("POST", "/invites/accept", { token: bobToken, body: { token: (inv.body as { token: string }).token } }));
// Bob (member) cannot invite.
const bobInvite = await app.handle(reqOf("POST", "/teams/acme/invites", { token: bobToken, body: { email: "carol@example.com" } }));
expect(bobInvite.status).toBe(403);
// Eve can't accept Bob's-email invite.
const inv2 = await app.handle(reqOf("POST", "/teams/acme/invites", { token: ownerToken, body: { email: "carol@example.com" } }));
const eveAccept = await app.handle(reqOf("POST", "/invites/accept", { token: eveToken, body: { token: (inv2.body as { token: string }).token } }));
expect(eveAccept.status).toBe(403);
});
it("blocks a non-member from a team's vault entirely", async () => {
const ownerToken = await login(app, "owner@example.com");
const strangerToken = await login(app, "stranger@example.com");
await app.handle(reqOf("POST", "/teams", { token: ownerToken, body: { slug: "acme" } }));
const v = await app.handle(reqOf("POST", "/teams/acme/vaults", { token: ownerToken, body: { name: "prod" } }));
const vaultId = (v.body as { vault: { id: string } }).vault.id;
const stranger = await app.handle(reqOf("GET", `/vaults/${vaultId}/secrets`, { token: strangerToken }));
expect(stranger.status).toBe(403);
const strangerMembers = await app.handle(reqOf("GET", "/teams/acme/members", { token: strangerToken }));
expect(strangerMembers.status).toBe(403);
});
});

View file

@ -1,391 +0,0 @@
import { randomBytes, createHash } from "node:crypto";
import { hashToken, normalizeEmail, type CredShareStore } from "./store.js";
import type { CredShareMember, MemberRole } from "./types.js";
export interface CredShareRequest {
method: string;
/** Path AFTER the /api/credshare prefix, e.g. "/teams/acme/members". */
path: string;
query: URLSearchParams;
body: unknown;
/** Bearer token (without the "Bearer " prefix), if present. */
token?: string;
}
export interface CredShareResponse {
status: number;
body: unknown;
}
export interface CredShareEmailSender {
sendLoginCode(email: string, code: string): Promise<void>;
sendInvite(input: { email: string; token: string; teamName: string; teamSlug: string; invitedByEmail: string }): Promise<void>;
}
export interface CredShareApiOptions {
store: CredShareStore;
email?: CredShareEmailSender;
/** Public base URL used to build invite-accept links in emails. */
webBaseUrl?: string;
now?: () => Date;
/** Test seams. */
generateCode?: () => string;
generateToken?: () => string;
loginCodeTtlMs?: number;
inviteTtlMs?: number;
}
const ROLE_RANK: Record<MemberRole, number> = { member: 0, admin: 1, owner: 2 };
function ok(body: unknown, status = 200): CredShareResponse {
return { status, body };
}
function err(status: number, message: string): CredShareResponse {
return { status, body: { error: message } };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function str(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
/** A member view safe to return to teammates (no tokens/keys, just identity + status). */
function memberView(m: CredShareMember, publicKey: string | null | undefined, hasGrant?: boolean) {
return {
email: m.email,
role: m.role,
status: m.status,
hasPublicKey: Boolean(publicKey),
...(hasGrant === undefined ? {} : { hasVaultAccess: hasGrant }),
joinedAt: m.joinedAt
};
}
export function createCredShareApi(options: CredShareApiOptions) {
const { store, email } = options;
const now = options.now ?? (() => new Date());
const generateCode = options.generateCode ?? (() => String(randomBytes(4).readUInt32BE(0) % 1_000_000).padStart(6, "0"));
const generateToken = options.generateToken ?? (() => randomBytes(32).toString("base64url"));
const loginCodeTtlMs = options.loginCodeTtlMs ?? 10 * 60 * 1000;
const inviteTtlMs = options.inviteTtlMs ?? 7 * 24 * 60 * 60 * 1000;
async function currentUserId(req: CredShareRequest): Promise<string | undefined> {
if (!req.token) return undefined;
return store.getUserIdForToken(hashToken(req.token));
}
type MemberGuard =
| { ok: true; team: Awaited<ReturnType<CredShareStore["getTeamBySlug"]>> & object; member: CredShareMember }
| { ok: false; response: CredShareResponse };
async function requireMember(teamSlug: string, userId: string): Promise<MemberGuard> {
const team = await store.getTeamBySlug(teamSlug);
if (!team) return { ok: false, response: err(404, `Unknown team: ${teamSlug}`) };
const member = await store.getMember(team.id, userId);
if (!member || member.status !== "active") {
return { ok: false, response: err(403, "You are not a member of this team.") };
}
return { ok: true, team, member };
}
async function handle(req: CredShareRequest): Promise<CredShareResponse> {
const { method, path } = req;
// ---- auth (unauthenticated) ------------------------------------------
if (method === "POST" && path === "/auth/request") {
if (!isRecord(req.body) || !str(req.body.email)) return err(422, "Expected { email }.");
const emailAddr = normalizeEmail(str(req.body.email)!);
const code = generateCode();
await store.saveLoginCode({
email: emailAddr,
codeHash: createHash("sha256").update(code).digest("hex"),
expiresAt: new Date(now().getTime() + loginCodeTtlMs).toISOString(),
createdAt: now().toISOString()
});
let delivered = false;
if (email) {
await email.sendLoginCode(emailAddr, code);
delivered = true;
}
// When no email transport is configured (local dev/tests), echo the code so
// the flow is still usable. Never echo it once real delivery is on.
return ok({ ok: true, emailSent: delivered, ...(delivered ? {} : { devCode: code }) });
}
if (method === "POST" && path === "/auth/verify") {
if (!isRecord(req.body) || !str(req.body.email) || !str(req.body.code)) return err(422, "Expected { email, code }.");
const emailAddr = normalizeEmail(str(req.body.email)!);
const codeHash = createHash("sha256").update(str(req.body.code)!).digest("hex");
const valid = await store.consumeLoginCode(emailAddr, codeHash, now());
if (!valid) return err(401, "Invalid or expired code.");
const user = await store.upsertUserByEmail(emailAddr);
const token = generateToken();
await store.saveToken({ tokenHash: hashToken(token), userId: user.id, createdAt: now().toISOString(), lastUsedAt: null });
return ok({ token, user: { id: user.id, email: user.email, publicKey: user.publicKey } });
}
// ---- everything below requires a valid token -------------------------
const userId = await currentUserId(req);
if (!userId) return err(401, "Missing or invalid token. Run: logicsrc login --email you@example.com");
const me = await store.getUserById(userId);
if (!me) return err(401, "Unknown user for token.");
if (method === "POST" && path === "/logout") {
if (req.token) await store.deleteToken(hashToken(req.token));
return ok({ ok: true });
}
if (method === "POST" && path === "/keys") {
if (!isRecord(req.body) || !str(req.body.publicKey)) return err(422, "Expected { publicKey }.");
const updated = await store.setUserPublicKey(userId, str(req.body.publicKey)!);
return ok({ email: updated.email, publicKey: updated.publicKey });
}
if (method === "GET" && path === "/me") {
const teams = await store.listTeamsForUser(userId);
return ok({ user: { id: me.id, email: me.email, publicKey: me.publicKey }, teams });
}
if (method === "GET" && path === "/users") {
const target = req.query.get("email");
if (!target) return err(422, "Expected ?email=");
const user = await store.getUserByEmail(target);
return ok({ email: normalizeEmail(target), userId: user?.id ?? null, publicKey: user?.publicKey ?? null });
}
// ---- teams -----------------------------------------------------------
if (method === "POST" && path === "/teams") {
if (!isRecord(req.body) || !str(req.body.slug)) return err(422, "Expected { slug, name? }.");
const slug = normalizeSlug(str(req.body.slug)!);
if (!slug) return err(422, "Slug must be lowercase letters, numbers, and dashes.");
if (await store.getTeamBySlug(slug)) return err(409, `Team slug "${slug}" is taken.`);
const team = await store.createTeam({ slug, name: str(req.body.name) ?? slug, createdBy: userId });
await store.addMember({
teamId: team.id,
userId,
email: me.email,
role: "owner",
status: "active",
invitedBy: null,
joinedAt: now().toISOString()
});
await store.appendAudit({ teamId: team.id, vaultId: null, actorUserId: userId, action: "team:create", keyName: null, fingerprint: null });
return ok({ team }, 201);
}
if (method === "GET" && path === "/teams") {
return ok({ teams: await store.listTeamsForUser(userId) });
}
const membersMatch = /^\/teams\/([^/]+)\/members$/.exec(path);
if (membersMatch && method === "GET") {
const guard = await requireMember(decodeURIComponent(membersMatch[1]), userId);
if (!guard.ok) return guard.response;
const rows = await store.listMembers(guard.team.id);
const views = await Promise.all(
rows.map(async (m) => {
const u = m.userId ? await store.getUserById(m.userId) : undefined;
return memberView(m, u?.publicKey);
})
);
return ok({ members: views });
}
// ---- invites ---------------------------------------------------------
const invitesMatch = /^\/teams\/([^/]+)\/invites$/.exec(path);
if (invitesMatch && method === "POST") {
const guard = await requireMember(decodeURIComponent(invitesMatch[1]), userId);
if (!guard.ok) return guard.response;
if (ROLE_RANK[guard.member.role] < ROLE_RANK.admin) return err(403, "Only owners and admins can invite.");
if (!isRecord(req.body) || !str(req.body.email)) return err(422, "Expected { email, role? }.");
const inviteEmail = normalizeEmail(str(req.body.email)!);
const role = (str(req.body.role) as MemberRole) ?? "member";
if (!(role in ROLE_RANK)) return err(422, "role must be owner|admin|member.");
// Ensure a (possibly invited) member row exists.
const existing = await store.getMemberByEmail(guard.team.id, inviteEmail);
if (!existing) {
const invitedUser = await store.getUserByEmail(inviteEmail);
await store.addMember({
teamId: guard.team.id,
userId: invitedUser?.id ?? null,
email: inviteEmail,
role,
status: "invited",
invitedBy: userId,
joinedAt: null
});
}
const token = generateToken();
const invite = await store.createInvite({
teamId: guard.team.id,
email: inviteEmail,
role,
tokenHash: hashToken(token),
createdBy: userId,
expiresAt: new Date(now().getTime() + inviteTtlMs).toISOString()
});
await store.appendAudit({ teamId: guard.team.id, vaultId: null, actorUserId: userId, action: "team:invite", keyName: inviteEmail, fingerprint: null });
let delivered = false;
if (email) {
await email.sendInvite({ email: inviteEmail, token, teamName: guard.team.name, teamSlug: guard.team.slug, invitedByEmail: me.email });
delivered = true;
}
return ok(
{ invite: { id: invite.id, email: invite.email, role: invite.role, expiresAt: invite.expiresAt }, emailSent: delivered, ...(delivered ? {} : { token }) },
201
);
}
if (method === "POST" && path === "/invites/accept") {
if (!isRecord(req.body) || !str(req.body.token)) return err(422, "Expected { token }.");
const invite = await store.getInviteByTokenHash(hashToken(str(req.body.token)!));
if (!invite) return err(404, "Invite not found.");
if (invite.acceptedAt) return err(409, "Invite already used.");
if (new Date(invite.expiresAt).getTime() < now().getTime()) return err(410, "Invite expired.");
if (invite.email !== me.email) return err(403, `This invite is for ${invite.email}, not ${me.email}.`);
await store.updateMember(invite.teamId, me.email, { userId, status: "active", joinedAt: now().toISOString() });
await store.markInviteAccepted(invite.id, now().toISOString());
await store.appendAudit({ teamId: invite.teamId, vaultId: null, actorUserId: userId, action: "team:join", keyName: null, fingerprint: null });
const team = (await store.listTeamsForUser(userId)).find((t) => t.id === invite.teamId);
return ok({ ok: true, team });
}
// ---- vaults ----------------------------------------------------------
const vaultsMatch = /^\/teams\/([^/]+)\/vaults$/.exec(path);
if (vaultsMatch) {
const guard = await requireMember(decodeURIComponent(vaultsMatch[1]), userId);
if (!guard.ok) return guard.response;
if (method === "GET") {
const vaults = await store.listVaults(guard.team.id);
const rows = await Promise.all(
vaults.map(async (v) => ({
id: v.id,
name: v.name,
hasAccess: Boolean(await store.getGrant(v.id, userId)),
secretCount: (await store.listSecrets(v.id)).length
}))
);
return ok({ vaults: rows });
}
if (method === "POST") {
if (!isRecord(req.body) || !str(req.body.name)) return err(422, "Expected { name }.");
const name = normalizeSlug(str(req.body.name)!);
if (!name) return err(422, "Vault name must be lowercase letters, numbers, and dashes.");
const existing = await store.getVault(guard.team.id, name);
if (existing) return ok({ vault: existing });
const vault = await store.createVault({ teamId: guard.team.id, name, createdBy: userId });
await store.appendAudit({ teamId: guard.team.id, vaultId: vault.id, actorUserId: userId, action: "vault:create", keyName: null, fingerprint: null });
return ok({ vault }, 201);
}
}
// ---- vault grants / secrets / audit (addressed by vault id) ----------
const vaultIdMatch = /^\/vaults\/([^/]+)\/(grant|grants|secrets|audit)$/.exec(path);
if (vaultIdMatch) {
const vaultId = decodeURIComponent(vaultIdMatch[1]);
const sub = vaultIdMatch[2];
const vault = await store.getVaultById(vaultId);
if (!vault) return err(404, "Unknown vault.");
const member = await store.getMember(vault.teamId, userId);
if (!member || member.status !== "active") return err(403, "You are not a member of this vault's team.");
if (sub === "grant" && method === "GET") {
const grant = await store.getGrant(vaultId, userId);
if (!grant) return err(403, "You do not have access to this vault yet. Ask a member to grant you.");
return ok({ wrappedDek: grant.wrappedDek });
}
if (sub === "grants") {
if (method === "GET") {
const grants = await store.listGrants(vaultId);
const grantedUserIds = new Set(grants.map((g) => g.userId));
const members = await store.listMembers(vault.teamId);
const rows = await Promise.all(
members.map(async (m) => {
const u = m.userId ? await store.getUserById(m.userId) : undefined;
return { email: m.email, hasPublicKey: Boolean(u?.publicKey), hasAccess: Boolean(m.userId && grantedUserIds.has(m.userId)) };
})
);
return ok({ grants: rows });
}
if (method === "POST") {
// The caller must already hold access (they can produce a valid wrapped DEK).
const iHold = await store.getGrant(vaultId, userId);
if (!iHold && (await store.listGrants(vaultId)).length > 0) {
return err(403, "Only a member with vault access can grant others.");
}
if (!isRecord(req.body) || !str(req.body.wrappedDek) || !str(req.body.email)) {
return err(422, "Expected { email, wrappedDek }.");
}
const targetUser = await store.getUserByEmail(str(req.body.email)!);
if (!targetUser) return err(404, "Target user has not logged in yet.");
if (!targetUser.publicKey) return err(409, "Target user has not uploaded a public key yet.");
await store.upsertGrant({ vaultId, userId: targetUser.id, wrappedDek: str(req.body.wrappedDek)!, grantedBy: userId, createdAt: now().toISOString() });
await store.appendAudit({ teamId: vault.teamId, vaultId, actorUserId: userId, action: "vault:grant", keyName: targetUser.email, fingerprint: null });
return ok({ ok: true }, 201);
}
}
if (sub === "secrets") {
if (method === "GET") {
const secrets = await store.listSecrets(vaultId);
return ok({
vaultId,
secrets: secrets.map((s) => ({ name: s.name, nonce: s.nonce, ciphertext: s.ciphertext, fingerprint: s.fingerprint, version: s.version, updatedAt: s.updatedAt }))
});
}
if (method === "PUT") {
if (!isRecord(req.body)) return err(422, "Expected { upserts?, deletes? }.");
const upserts = Array.isArray(req.body.upserts) ? req.body.upserts : [];
const deletes = Array.isArray(req.body.deletes) ? req.body.deletes : [];
const existing = new Map((await store.listSecrets(vaultId)).map((s) => [s.name, s]));
const applied: string[] = [];
for (const raw of upserts) {
if (!isRecord(raw) || !str(raw.name) || !str(raw.nonce) || !str(raw.ciphertext) || !str(raw.fingerprint)) {
return err(422, "Each upsert needs { name, nonce, ciphertext, fingerprint }.");
}
const name = str(raw.name)!;
const prev = existing.get(name);
await store.putSecret({
vaultId,
name,
nonce: str(raw.nonce)!,
ciphertext: str(raw.ciphertext)!,
fingerprint: str(raw.fingerprint)!,
version: (prev?.version ?? 0) + 1,
updatedBy: userId,
updatedAt: now().toISOString()
});
await store.appendAudit({ teamId: vault.teamId, vaultId, actorUserId: userId, action: prev ? "secret:update" : "secret:add", keyName: name, fingerprint: str(raw.fingerprint)! });
applied.push(name);
}
for (const raw of deletes) {
const name = str(raw);
if (!name) continue;
await store.deleteSecret(vaultId, name);
await store.appendAudit({ teamId: vault.teamId, vaultId, actorUserId: userId, action: "secret:remove", keyName: name, fingerprint: null });
applied.push(name);
}
return ok({ ok: true, applied });
}
}
if (sub === "audit" && method === "GET") {
return ok({ audit: await store.listAudit(vaultId) });
}
}
return err(404, "Not found");
}
return { handle };
}
function normalizeSlug(input: string): string | undefined {
const slug = input.trim().toLowerCase();
return /^[a-z0-9][a-z0-9-]{0,62}$/.test(slug) ? slug : undefined;
}

View file

@ -1,243 +0,0 @@
import { randomUUID, createHash } from "node:crypto";
import type {
CredShareUser,
CredShareTeam,
CredShareMember,
CredShareInvite,
CredShareVault,
CredShareGrant,
CredShareSecret,
CredShareAuditEvent,
CredShareLoginCode,
CredShareToken,
MemberRole,
MemberStatus
} from "./types.js";
/**
* Storage boundary for the credential-sharing server. The router talks only to
* this interface, so it can run against an in-memory map (tests / local dev) or
* Supabase (production) without changing a line of route logic.
*/
export interface CredShareStore {
// users + keys
upsertUserByEmail(email: string): Promise<CredShareUser>;
getUserByEmail(email: string): Promise<CredShareUser | undefined>;
getUserById(id: string): Promise<CredShareUser | undefined>;
setUserPublicKey(userId: string, publicKey: string): Promise<CredShareUser>;
// auth
saveLoginCode(code: CredShareLoginCode): Promise<void>;
consumeLoginCode(email: string, codeHash: string, now: Date): Promise<boolean>;
saveToken(token: CredShareToken): Promise<void>;
getUserIdForToken(tokenHash: string): Promise<string | undefined>;
deleteToken(tokenHash: string): Promise<void>;
// teams + members
createTeam(input: { slug: string; name: string; createdBy: string }): Promise<CredShareTeam>;
getTeamBySlug(slug: string): Promise<CredShareTeam | undefined>;
listTeamsForUser(userId: string): Promise<CredShareTeam[]>;
addMember(input: Omit<CredShareMember, "id" | "createdAt">): Promise<CredShareMember>;
getMember(teamId: string, userId: string): Promise<CredShareMember | undefined>;
getMemberByEmail(teamId: string, email: string): Promise<CredShareMember | undefined>;
listMembers(teamId: string): Promise<CredShareMember[]>;
updateMember(
teamId: string,
email: string,
patch: Partial<Pick<CredShareMember, "userId" | "status" | "joinedAt" | "role">>
): Promise<CredShareMember | undefined>;
// invites
createInvite(input: Omit<CredShareInvite, "id" | "createdAt" | "acceptedAt">): Promise<CredShareInvite>;
getInviteByTokenHash(tokenHash: string): Promise<CredShareInvite | undefined>;
markInviteAccepted(id: string, at: string): Promise<void>;
// vaults + grants + secrets
createVault(input: { teamId: string; name: string; createdBy: string }): Promise<CredShareVault>;
getVault(teamId: string, name: string): Promise<CredShareVault | undefined>;
getVaultById(vaultId: string): Promise<CredShareVault | undefined>;
listVaults(teamId: string): Promise<CredShareVault[]>;
upsertGrant(grant: CredShareGrant): Promise<void>;
getGrant(vaultId: string, userId: string): Promise<CredShareGrant | undefined>;
listGrants(vaultId: string): Promise<CredShareGrant[]>;
listSecrets(vaultId: string): Promise<CredShareSecret[]>;
putSecret(secret: CredShareSecret): Promise<void>;
deleteSecret(vaultId: string, name: string): Promise<void>;
// audit
appendAudit(event: Omit<CredShareAuditEvent, "id" | "createdAt">): Promise<CredShareAuditEvent>;
listAudit(vaultId: string): Promise<CredShareAuditEvent[]>;
}
export function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
/** In-memory store — used by tests and single-process local dev. */
export function createMemoryCredShareStore(): CredShareStore {
const users = new Map<string, CredShareUser>(); // by id
const usersByEmail = new Map<string, string>(); // email -> id
const loginCodes = new Map<string, CredShareLoginCode>(); // email -> code
const tokens = new Map<string, CredShareToken>(); // tokenHash -> token
const teams = new Map<string, CredShareTeam>(); // by id
const teamsBySlug = new Map<string, string>(); // slug -> id
const members: CredShareMember[] = [];
const invites = new Map<string, CredShareInvite>(); // by id
const vaults = new Map<string, CredShareVault>(); // by id
const grants = new Map<string, CredShareGrant>(); // `${vaultId}:${userId}`
const secrets = new Map<string, CredShareSecret>(); // `${vaultId}:${name}`
const audit: CredShareAuditEvent[] = [];
const iso = () => new Date().toISOString();
return {
async upsertUserByEmail(email) {
const key = normalizeEmail(email);
const existingId = usersByEmail.get(key);
if (existingId) {
return users.get(existingId)!;
}
const user: CredShareUser = { id: randomUUID(), email: key, publicKey: null, createdAt: iso() };
users.set(user.id, user);
usersByEmail.set(key, user.id);
return user;
},
async getUserByEmail(email) {
const id = usersByEmail.get(normalizeEmail(email));
return id ? users.get(id) : undefined;
},
async getUserById(id) {
return users.get(id);
},
async setUserPublicKey(userId, publicKey) {
const user = users.get(userId);
if (!user) throw new Error("user not found");
user.publicKey = publicKey;
return user;
},
async saveLoginCode(code) {
loginCodes.set(normalizeEmail(code.email), code);
},
async consumeLoginCode(email, codeHash, now) {
const key = normalizeEmail(email);
const code = loginCodes.get(key);
if (!code || code.codeHash !== codeHash || new Date(code.expiresAt).getTime() < now.getTime()) {
return false;
}
loginCodes.delete(key);
return true;
},
async saveToken(token) {
tokens.set(token.tokenHash, token);
},
async getUserIdForToken(tokenHash) {
const token = tokens.get(tokenHash);
if (token) {
token.lastUsedAt = iso();
}
return token?.userId;
},
async deleteToken(tokenHash) {
tokens.delete(tokenHash);
},
async createTeam({ slug, name, createdBy }) {
const team: CredShareTeam = { id: randomUUID(), slug, name, createdBy, createdAt: iso() };
teams.set(team.id, team);
teamsBySlug.set(slug, team.id);
return team;
},
async getTeamBySlug(slug) {
const id = teamsBySlug.get(slug);
return id ? teams.get(id) : undefined;
},
async listTeamsForUser(userId) {
const teamIds = new Set(members.filter((m) => m.userId === userId && m.status === "active").map((m) => m.teamId));
return [...teamIds].map((id) => teams.get(id)!).filter(Boolean);
},
async addMember(input) {
const member: CredShareMember = { ...input, id: randomUUID(), createdAt: iso() };
members.push(member);
return member;
},
async getMember(teamId, userId) {
return members.find((m) => m.teamId === teamId && m.userId === userId);
},
async getMemberByEmail(teamId, email) {
const key = normalizeEmail(email);
return members.find((m) => m.teamId === teamId && m.email === key);
},
async listMembers(teamId) {
return members.filter((m) => m.teamId === teamId);
},
async updateMember(teamId, email, patch) {
const key = normalizeEmail(email);
const member = members.find((m) => m.teamId === teamId && m.email === key);
if (!member) return undefined;
Object.assign(member, patch);
return member;
},
async createInvite(input) {
const invite: CredShareInvite = { ...input, id: randomUUID(), acceptedAt: null, createdAt: iso() };
invites.set(invite.id, invite);
return invite;
},
async getInviteByTokenHash(tokenHash) {
return [...invites.values()].find((i) => i.tokenHash === tokenHash);
},
async markInviteAccepted(id, at) {
const invite = invites.get(id);
if (invite) invite.acceptedAt = at;
},
async createVault({ teamId, name, createdBy }) {
const vault: CredShareVault = { id: randomUUID(), teamId, name, createdBy, createdAt: iso() };
vaults.set(vault.id, vault);
return vault;
},
async getVault(teamId, name) {
return [...vaults.values()].find((v) => v.teamId === teamId && v.name === name);
},
async getVaultById(vaultId) {
return vaults.get(vaultId);
},
async listVaults(teamId) {
return [...vaults.values()].filter((v) => v.teamId === teamId);
},
async upsertGrant(grant) {
grants.set(`${grant.vaultId}:${grant.userId}`, grant);
},
async getGrant(vaultId, userId) {
return grants.get(`${vaultId}:${userId}`);
},
async listGrants(vaultId) {
return [...grants.values()].filter((g) => g.vaultId === vaultId);
},
async listSecrets(vaultId) {
return [...secrets.values()].filter((s) => s.vaultId === vaultId).sort((a, b) => a.name.localeCompare(b.name));
},
async putSecret(secret) {
secrets.set(`${secret.vaultId}:${secret.name}`, secret);
},
async deleteSecret(vaultId, name) {
secrets.delete(`${vaultId}:${name}`);
},
async appendAudit(event) {
const full: CredShareAuditEvent = { ...event, id: randomUUID(), createdAt: iso() };
audit.push(full);
return full;
},
async listAudit(vaultId) {
return audit.filter((e) => e.vaultId === vaultId).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
};
}
export type { MemberRole, MemberStatus };

View file

@ -1,238 +0,0 @@
import { createRequire } from "node:module";
import type { CredShareStore } from "./store.js";
import { normalizeEmail } from "./store.js";
import type {
CredShareUser,
CredShareTeam,
CredShareMember,
CredShareInvite,
CredShareVault,
CredShareGrant,
CredShareSecret,
CredShareAuditEvent,
CredShareLoginCode,
CredShareToken
} from "./types.js";
/**
* Supabase-backed CredShareStore (production). Uses the SERVICE ROLE key and
* enforces authorization in the router, not via RLS RLS on these tables is
* deny-by-default defense-in-depth. Returns `undefined` when unconfigured so
* the API falls back to the in-memory store for local dev.
*
* Table columns are snake_case; this module maps to/from the camelCase domain
* types. See supabase/migrations/*_credshare.sql.
*/
export function createSupabaseCredShareStore(): CredShareStore | undefined {
const url = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
if (!url || !key) return undefined;
// Lazy-require so the API runs without @supabase/supabase-js installed when
// Supabase isn't configured (the memory store needs no dependency).
const require = createRequire(import.meta.url);
const { createClient } = require("@supabase/supabase-js") as typeof import("@supabase/supabase-js");
const db = createClient(url, key, { auth: { persistSession: false } });
const T = {
users: "credshare_users",
loginCodes: "credshare_login_codes",
tokens: "credshare_tokens",
teams: "credshare_teams",
members: "credshare_members",
invites: "credshare_invites",
vaults: "credshare_vaults",
grants: "credshare_vault_grants",
secrets: "credshare_secrets",
audit: "credshare_audit"
} as const;
const userOut = (r: Record<string, unknown>): CredShareUser => ({ id: r.id as string, email: r.email as string, publicKey: (r.public_key as string) ?? null, createdAt: r.created_at as string });
const teamOut = (r: Record<string, unknown>): CredShareTeam => ({ id: r.id as string, slug: r.slug as string, name: r.name as string, createdBy: r.created_by as string, createdAt: r.created_at as string });
const memberOut = (r: Record<string, unknown>): CredShareMember => ({ id: r.id as string, teamId: r.team_id as string, userId: (r.user_id as string) ?? null, email: r.email as string, role: r.role as CredShareMember["role"], status: r.status as CredShareMember["status"], invitedBy: (r.invited_by as string) ?? null, createdAt: r.created_at as string, joinedAt: (r.joined_at as string) ?? null });
const vaultOut = (r: Record<string, unknown>): CredShareVault => ({ id: r.id as string, teamId: r.team_id as string, name: r.name as string, createdBy: r.created_by as string, createdAt: r.created_at as string });
const secretOut = (r: Record<string, unknown>): CredShareSecret => ({ vaultId: r.vault_id as string, name: r.name as string, nonce: r.nonce as string, ciphertext: r.ciphertext as string, fingerprint: r.fingerprint as string, version: r.version as number, updatedBy: r.updated_by as string, updatedAt: r.updated_at as string });
const grantOut = (r: Record<string, unknown>): CredShareGrant => ({ vaultId: r.vault_id as string, userId: r.user_id as string, wrappedDek: r.wrapped_dek as string, grantedBy: r.granted_by as string, createdAt: r.created_at as string });
const auditOut = (r: Record<string, unknown>): CredShareAuditEvent => ({ id: r.id as string, teamId: (r.team_id as string) ?? null, vaultId: (r.vault_id as string) ?? null, actorUserId: r.actor_user_id as string, action: r.action as string, keyName: (r.key_name as string) ?? null, fingerprint: (r.fingerprint as string) ?? null, createdAt: r.created_at as string });
async function one<T>(query: PromiseLike<{ data: unknown; error: unknown }>, map: (r: Record<string, unknown>) => T): Promise<T | undefined> {
const { data, error } = await query;
if (error) throw error;
return data ? map(data as Record<string, unknown>) : undefined;
}
return {
async upsertUserByEmail(email) {
const key = normalizeEmail(email);
const existing = await one(db.from(T.users).select("*").eq("email", key).maybeSingle(), userOut);
if (existing) return existing;
const { data, error } = await db.from(T.users).insert({ email: key }).select("*").single();
if (error) throw error;
return userOut(data);
},
async getUserByEmail(email) {
return one(db.from(T.users).select("*").eq("email", normalizeEmail(email)).maybeSingle(), userOut);
},
async getUserById(id) {
return one(db.from(T.users).select("*").eq("id", id).maybeSingle(), userOut);
},
async setUserPublicKey(userId, publicKey) {
const { data, error } = await db.from(T.users).update({ public_key: publicKey }).eq("id", userId).select("*").single();
if (error) throw error;
return userOut(data);
},
async saveLoginCode(code: CredShareLoginCode) {
await db.from(T.loginCodes).delete().eq("email", code.email);
const { error } = await db.from(T.loginCodes).insert({ email: code.email, code_hash: code.codeHash, expires_at: code.expiresAt });
if (error) throw error;
},
async consumeLoginCode(email, codeHash, nowDate) {
const { data, error } = await db.from(T.loginCodes).select("*").eq("email", normalizeEmail(email)).eq("code_hash", codeHash).maybeSingle();
if (error) throw error;
if (!data || new Date(data.expires_at as string).getTime() < nowDate.getTime()) return false;
await db.from(T.loginCodes).delete().eq("email", normalizeEmail(email));
return true;
},
async saveToken(token: CredShareToken) {
const { error } = await db.from(T.tokens).insert({ token_hash: token.tokenHash, user_id: token.userId });
if (error) throw error;
},
async getUserIdForToken(tokenHash) {
const { data, error } = await db.from(T.tokens).select("user_id").eq("token_hash", tokenHash).maybeSingle();
if (error) throw error;
if (data) await db.from(T.tokens).update({ last_used_at: new Date().toISOString() }).eq("token_hash", tokenHash);
return (data?.user_id as string) ?? undefined;
},
async deleteToken(tokenHash) {
await db.from(T.tokens).delete().eq("token_hash", tokenHash);
},
async createTeam({ slug, name, createdBy }) {
const { data, error } = await db.from(T.teams).insert({ slug, name, created_by: createdBy }).select("*").single();
if (error) throw error;
return teamOut(data);
},
async getTeamBySlug(slug) {
return one(db.from(T.teams).select("*").eq("slug", slug).maybeSingle(), teamOut);
},
async listTeamsForUser(userId) {
const { data, error } = await db.from(T.members).select("team_id").eq("user_id", userId).eq("status", "active");
if (error) throw error;
const ids = (data ?? []).map((r) => r.team_id as string);
if (ids.length === 0) return [];
const { data: teams, error: teamErr } = await db.from(T.teams).select("*").in("id", ids);
if (teamErr) throw teamErr;
return (teams ?? []).map(teamOut);
},
async addMember(input) {
const { data, error } = await db
.from(T.members)
.insert({ team_id: input.teamId, user_id: input.userId, email: input.email, role: input.role, status: input.status, invited_by: input.invitedBy, joined_at: input.joinedAt })
.select("*")
.single();
if (error) throw error;
return memberOut(data);
},
async getMember(teamId, userId) {
return one(db.from(T.members).select("*").eq("team_id", teamId).eq("user_id", userId).maybeSingle(), memberOut);
},
async getMemberByEmail(teamId, email) {
return one(db.from(T.members).select("*").eq("team_id", teamId).eq("email", normalizeEmail(email)).maybeSingle(), memberOut);
},
async listMembers(teamId) {
const { data, error } = await db.from(T.members).select("*").eq("team_id", teamId).order("created_at");
if (error) throw error;
return (data ?? []).map(memberOut);
},
async updateMember(teamId, email, patch) {
const update: Record<string, unknown> = {};
if (patch.userId !== undefined) update.user_id = patch.userId;
if (patch.status !== undefined) update.status = patch.status;
if (patch.joinedAt !== undefined) update.joined_at = patch.joinedAt;
if (patch.role !== undefined) update.role = patch.role;
return one(db.from(T.members).update(update).eq("team_id", teamId).eq("email", normalizeEmail(email)).select("*").maybeSingle(), memberOut);
},
async createInvite(input) {
const { data, error } = await db
.from(T.invites)
.insert({ team_id: input.teamId, email: input.email, role: input.role, token_hash: input.tokenHash, created_by: input.createdBy, expires_at: input.expiresAt })
.select("*")
.single();
if (error) throw error;
const r = data as Record<string, unknown>;
return { id: r.id as string, teamId: r.team_id as string, email: r.email as string, role: r.role as CredShareInvite["role"], tokenHash: r.token_hash as string, createdBy: r.created_by as string, expiresAt: r.expires_at as string, acceptedAt: (r.accepted_at as string) ?? null, createdAt: r.created_at as string };
},
async getInviteByTokenHash(tokenHash) {
const { data, error } = await db.from(T.invites).select("*").eq("token_hash", tokenHash).maybeSingle();
if (error) throw error;
if (!data) return undefined;
const r = data as Record<string, unknown>;
return { id: r.id as string, teamId: r.team_id as string, email: r.email as string, role: r.role as CredShareInvite["role"], tokenHash: r.token_hash as string, createdBy: r.created_by as string, expiresAt: r.expires_at as string, acceptedAt: (r.accepted_at as string) ?? null, createdAt: r.created_at as string };
},
async markInviteAccepted(id, at) {
await db.from(T.invites).update({ accepted_at: at }).eq("id", id);
},
async createVault({ teamId, name, createdBy }) {
const { data, error } = await db.from(T.vaults).insert({ team_id: teamId, name, created_by: createdBy }).select("*").single();
if (error) throw error;
return vaultOut(data);
},
async getVault(teamId, name) {
return one(db.from(T.vaults).select("*").eq("team_id", teamId).eq("name", name).maybeSingle(), vaultOut);
},
async getVaultById(vaultId) {
return one(db.from(T.vaults).select("*").eq("id", vaultId).maybeSingle(), vaultOut);
},
async listVaults(teamId) {
const { data, error } = await db.from(T.vaults).select("*").eq("team_id", teamId).order("name");
if (error) throw error;
return (data ?? []).map(vaultOut);
},
async upsertGrant(grant) {
const { error } = await db
.from(T.grants)
.upsert({ vault_id: grant.vaultId, user_id: grant.userId, wrapped_dek: grant.wrappedDek, granted_by: grant.grantedBy }, { onConflict: "vault_id,user_id" });
if (error) throw error;
},
async getGrant(vaultId, userId) {
return one(db.from(T.grants).select("*").eq("vault_id", vaultId).eq("user_id", userId).maybeSingle(), grantOut);
},
async listGrants(vaultId) {
const { data, error } = await db.from(T.grants).select("*").eq("vault_id", vaultId);
if (error) throw error;
return (data ?? []).map(grantOut);
},
async listSecrets(vaultId) {
const { data, error } = await db.from(T.secrets).select("*").eq("vault_id", vaultId).order("name");
if (error) throw error;
return (data ?? []).map(secretOut);
},
async putSecret(secret) {
const { error } = await db
.from(T.secrets)
.upsert({ vault_id: secret.vaultId, name: secret.name, nonce: secret.nonce, ciphertext: secret.ciphertext, fingerprint: secret.fingerprint, version: secret.version, updated_by: secret.updatedBy, updated_at: secret.updatedAt }, { onConflict: "vault_id,name" });
if (error) throw error;
},
async deleteSecret(vaultId, name) {
await db.from(T.secrets).delete().eq("vault_id", vaultId).eq("name", name);
},
async appendAudit(event) {
const { data, error } = await db
.from(T.audit)
.insert({ team_id: event.teamId, vault_id: event.vaultId, actor_user_id: event.actorUserId, action: event.action, key_name: event.keyName, fingerprint: event.fingerprint })
.select("*")
.single();
if (error) throw error;
return auditOut(data);
},
async listAudit(vaultId) {
const { data, error } = await db.from(T.audit).select("*").eq("vault_id", vaultId).order("created_at", { ascending: false });
if (error) throw error;
return (data ?? []).map(auditOut);
}
};
}

View file

@ -1,107 +0,0 @@
/**
* 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;
}

View file

@ -11,10 +11,6 @@ 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]);
@ -61,42 +57,6 @@ 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");
@ -125,7 +85,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/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"]
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"]
});
return;
}
@ -260,11 +220,6 @@ 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;