feat(credential-sharing): end-to-end-encrypted team credential sharing

Adds a `team` credential provider + team/member management so teammates can
share secrets by email instead of passing .env files over chat. Fully E2E:
the server only ever stores ciphertext, per-member sealed vault keys, and
public keys — it never sees a plaintext value or the vault DEK.

Plugin (@logicsrc/plugin-credential-sharing)
- crypto.ts: X25519 identity keys, per-vault DEK (secretbox), DEK sealed to
  each member's pubkey (crypto_box_seal), value encrypt/decrypt (libsodium)
- identity.ts: local ~/.logicsrc/identity.json (0600) holding the device key
  + API token; never uploads the secret key
- client.ts: typed /api/credshare client
- providers/team.ts: `team:<slug>/<vault>` CredentialProvider (inspect,
  readValues=decrypt, write=encrypt, rollback); fingerprints match env so
  env<->team diffs line up
- fixes latent libsodium-wrappers ESM load bug (createRequire) here + in
  github-secrets

Server (commandboard-api /api/credshare)
- zero-knowledge router: email-code auth, keys, teams, members, invites,
  vaults, sealed grants, ciphertext secrets, audit; membership authz in app
- CredShareStore abstraction: in-memory (dev/tests) + Supabase (prod)
- Resend email transport for login codes + invites (no-op -> echoes locally)
- supabase migration: credshare_* tables, deny-by-default RLS

CLI
- real `logicsrc login` (email code -> token + key upload)
- `logicsrc teams create/list/invite/accept/members/vaults/grant/push/pull`

Web (logicsrc.com/teams + /teams/accept)
- management surface only (browser holds no private key, never decrypts):
  login, view teams/members/vaults, invite, accept

Tests: crypto round-trip, server contract (invite->accept->push->grant->pull
+ authz boundaries), and a real HTTP+client+crypto E2E asserting the server
never holds plaintext. Full workspace build + tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-07-13 13:09:11 +00:00
parent 257d581331
commit f057589d66
28 changed files with 2869 additions and 17 deletions

View file

@ -33,3 +33,15 @@ SH1PT_WEBHOOK_SECRET=
# webhook authenticates callers by this secret instead). Generate with: # webhook authenticates callers by this secret instead). Generate with:
# openssl rand -hex 32 # openssl rand -hex 32
BLOG_WEBHOOK_SECRET= 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 <noreply@logicsrc.com>
LOGICSRC_WEB_URL=https://logicsrc.com
# Web page → API base (public). Falls back to https://commandboard.run.
NEXT_PUBLIC_COMMANDBOARD_API_URL=

View file

@ -22,11 +22,13 @@
"@logicsrc/plugin-social-accounts": "file:../../plugins/social-accounts", "@logicsrc/plugin-social-accounts": "file:../../plugins/social-accounts",
"@logicsrc/plugin-ugig": "file:../../plugins/ugig", "@logicsrc/plugin-ugig": "file:../../plugins/ugig",
"@logicsrc/validators": "file:../../packages/validators", "@logicsrc/validators": "file:../../packages/validators",
"@supabase/supabase-js": "^2.105.4",
"imapflow": "^1.4.3", "imapflow": "^1.4.3",
"mailparser": "^3.9.12", "mailparser": "^3.9.12",
"nodemailer": "^9.0.1" "nodemailer": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"@logicsrc/plugin-credential-sharing": "file:../../plugins/credential-sharing",
"@types/mailparser": "^3.4.6", "@types/mailparser": "^3.4.6",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"tsx": "^4.21.0", "tsx": "^4.21.0",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,107 @@
/**
* Server-side types for the LogicSRC team credential-sharing API.
*
* The server is a ZERO-KNOWLEDGE relay for secret VALUES: it stores member
* public keys, per-member wrapped vault keys (sealed to those public keys), and
* secret ciphertext + nonces. It never receives or stores a plaintext secret or
* a vault data-encryption key.
*/
export type MemberRole = "owner" | "admin" | "member";
export type MemberStatus = "active" | "invited";
export interface CredShareUser {
id: string;
email: string;
/** X25519 identity public key (base64), or null until the member uploads one. */
publicKey: string | null;
createdAt: string;
}
export interface CredShareTeam {
id: string;
slug: string;
name: string;
createdBy: string;
createdAt: string;
}
export interface CredShareMember {
id: string;
teamId: string;
userId: string | null;
email: string;
role: MemberRole;
status: MemberStatus;
invitedBy: string | null;
createdAt: string;
joinedAt: string | null;
}
export interface CredShareInvite {
id: string;
teamId: string;
email: string;
role: MemberRole;
tokenHash: string;
createdBy: string;
expiresAt: string;
acceptedAt: string | null;
createdAt: string;
}
export interface CredShareVault {
id: string;
teamId: string;
name: string;
createdBy: string;
createdAt: string;
}
/** A vault DEK sealed to one member's public key. Server can't open it. */
export interface CredShareGrant {
vaultId: string;
userId: string;
wrappedDek: string;
grantedBy: string;
createdAt: string;
}
/** One secret, encrypted under the vault DEK. `fingerprint` is a salted hash for diffing. */
export interface CredShareSecret {
vaultId: string;
name: string;
nonce: string;
ciphertext: string;
fingerprint: string;
version: number;
updatedBy: string;
updatedAt: string;
}
export interface CredShareAuditEvent {
id: string;
teamId: string | null;
vaultId: string | null;
actorUserId: string;
action: string;
keyName: string | null;
fingerprint: string | null;
createdAt: string;
}
/** A pending email login code (the code itself is stored hashed). */
export interface CredShareLoginCode {
email: string;
codeHash: string;
expiresAt: string;
createdAt: string;
}
/** An issued API token (stored hashed). */
export interface CredShareToken {
tokenHash: string;
userId: string;
createdAt: string;
lastUsedAt: string | null;
}

View file

@ -11,6 +11,10 @@ import { listSocialAccountProviders, socialAccountsPlugin } from "@logicsrc/plug
import { uGigPlugin } from "@logicsrc/plugin-ugig"; import { uGigPlugin } from "@logicsrc/plugin-ugig";
import { schemas, validate } from "@logicsrc/validators"; import { schemas, validate } from "@logicsrc/validators";
import { buildAgentMailService, mailIdentity } from "./agentmail.js"; 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]); 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" } { 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 { class InvalidJsonBodyError extends Error {
constructor() { constructor() {
super("Invalid JSON body"); super("Invalid JSON body");
@ -85,7 +125,7 @@ async function route(request: IncomingMessage, response: ServerResponse) {
json(response, 200, { json(response, 200, {
ok: true, ok: true,
service: "commandboard-api", 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; return;
} }
@ -220,6 +260,11 @@ async function route(request: IncomingMessage, response: ServerResponse) {
return; 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/")) { if (url.pathname === "/api/plugins/agentmail" || url.pathname.startsWith("/api/plugins/agentmail/")) {
await handleAgentMail(request, response, url); await handleAgentMail(request, response, url);
return; return;

View file

@ -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<ReactNode> {
const { token } = await searchParams;
return (
<SiteShell active="Credentials">
<TeamsClient initialToken={token} />
</SiteShell>
);
}

View file

@ -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 (
<SiteShell active="Credentials">
<TeamsClient />
</SiteShell>
);
}

View file

@ -8,6 +8,7 @@ const NAV: Array<{ href: string; label: string; external?: boolean }> = [
{ href: "/agent-swarm", label: "Soon" }, { href: "/agent-swarm", label: "Soon" },
{ href: "/agentbyte", label: "AgentByte" }, { href: "/agentbyte", label: "AgentByte" },
{ href: "/credential-sharing", label: "Credentials" }, { href: "/credential-sharing", label: "Credentials" },
{ href: "/teams", label: "Teams" },
{ href: "/#cli", label: "CLI" }, { href: "/#cli", label: "CLI" },
{ href: "/docs", label: "Docs" }, { href: "/docs", label: "Docs" },
{ href: "/blog", label: "Blog" }, { href: "/blog", label: "Blog" },

View file

@ -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<T>(path: string, init: RequestInit & { token?: string } = {}): Promise<T> {
const headers: Record<string, string> = { accept: "application/json", ...(init.headers as Record<string, string>) };
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<string | null>(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<Team[]>([]);
const [active, setActive] = useState<string | null>(null);
const [members, setMembers] = useState<Member[]>([]);
const [vaults, setVaults] = useState<Vault[]>([]);
const [inviteEmail, setInviteEmail] = useState("");
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 && <p style={{ color: "#e5484d", margin: "0.5rem 0" }}> {error}</p>}
{status && !error && <p style={{ color: "#30a46c", margin: "0.5rem 0" }}>{status}</p>}
</>
);
if (!token) {
return (
<div className="band" style={{ maxWidth: "32rem" }}>
<div className="section-head">
<h2>Team credential sharing</h2>
<p>Log in by email to manage teams and invites. Secrets stay end-to-end encrypted decrypt them with the <code>logicsrc</code> CLI, never here.</p>
</div>
{notice}
{!codeSent ? (
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem", flexWrap: "wrap" }}>
<input type="email" placeholder="you@example.com" value={email} onChange={(e) => setEmail(e.target.value)} style={inputStyle} />
<button onClick={requestCode} disabled={busy || !email} style={buttonStyle}>Send code</button>
</div>
) : (
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem", flexWrap: "wrap" }}>
<input inputMode="numeric" placeholder="6-digit code" value={code} onChange={(e) => setCode(e.target.value)} style={inputStyle} />
<button onClick={verifyCode} disabled={busy || !code} style={buttonStyle}>Verify</button>
</div>
)}
</div>
);
}
return (
<div className="band" style={{ maxWidth: "48rem" }}>
<div className="section-head" style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: "1rem" }}>
<h2>Your teams</h2>
<span style={{ fontSize: "0.85rem", opacity: 0.7 }}>
{me?.email} {me && !me.publicKey && "· ⚠ no CLI key yet (run logicsrc login)"} · <a onClick={logout} style={{ cursor: "pointer" }}>log out</a>
</span>
</div>
{notice}
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", margin: "0.75rem 0" }}>
{teams.map((t) => (
<button key={t.slug} onClick={() => setActive(t.slug)} style={{ ...chipStyle, ...(active === t.slug ? chipActive : {}) }}>
{t.name || t.slug}
</button>
))}
<button onClick={createTeam} style={chipStyle}>+ new team</button>
</div>
{active && (
<>
<h3 style={{ marginTop: "1.5rem" }}>Members</h3>
<table style={tableStyle}>
<thead>
<tr><th style={thStyle}>Email</th><th style={thStyle}>Role</th><th style={thStyle}>Status</th><th style={thStyle}>CLI key</th></tr>
</thead>
<tbody>
{members.map((m) => (
<tr key={m.email}>
<td style={tdStyle}>{m.email}</td><td style={tdStyle}>{m.role}</td>
<td style={tdStyle}>{m.status}</td><td style={tdStyle}>{m.hasPublicKey ? "✓" : "—"}</td>
</tr>
))}
</tbody>
</table>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem", flexWrap: "wrap" }}>
<input type="email" placeholder="teammate@example.com" value={inviteEmail} onChange={(e) => setInviteEmail(e.target.value)} style={inputStyle} />
<button onClick={invite} disabled={busy || !inviteEmail} style={buttonStyle}>Invite</button>
</div>
<h3 style={{ marginTop: "1.5rem" }}>Vaults</h3>
{vaults.length === 0 ? (
<p style={{ opacity: 0.7 }}>No vaults yet. Create one from the CLI: <code>logicsrc teams push {active} prod</code></p>
) : (
<table style={tableStyle}>
<thead><tr><th style={thStyle}>Vault</th><th style={thStyle}>Secrets</th><th style={thStyle}>Your access</th></tr></thead>
<tbody>
{vaults.map((v) => (
<tr key={v.id}>
<td style={tdStyle}><code>{v.name}</code></td><td style={tdStyle}>{v.secretCount}</td>
<td style={tdStyle}>{v.hasAccess ? "✓ granted" : "— ask a member to grant you"}</td>
</tr>
))}
</tbody>
</table>
)}
<p style={{ opacity: 0.7, marginTop: "1rem", fontSize: "0.9rem" }}>
Pull secrets on your machine: <code>logicsrc teams pull {active} &lt;vault&gt;</code> values are decrypted locally with your key. The server (and this page) only ever see ciphertext.
</p>
</>
)}
</div>
);
}
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)" };

View file

@ -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. 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:<team-slug>/<vault-name>`
(`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 <token-from-email>
# …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.

2
package-lock.json generated
View file

@ -33,11 +33,13 @@
"@logicsrc/plugin-social-accounts": "file:../../plugins/social-accounts", "@logicsrc/plugin-social-accounts": "file:../../plugins/social-accounts",
"@logicsrc/plugin-ugig": "file:../../plugins/ugig", "@logicsrc/plugin-ugig": "file:../../plugins/ugig",
"@logicsrc/validators": "file:../../packages/validators", "@logicsrc/validators": "file:../../packages/validators",
"@supabase/supabase-js": "^2.105.4",
"imapflow": "^1.4.3", "imapflow": "^1.4.3",
"mailparser": "^3.9.12", "mailparser": "^3.9.12",
"nodemailer": "^9.0.1" "nodemailer": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"@logicsrc/plugin-credential-sharing": "file:../../plugins/credential-sharing",
"@types/mailparser": "^3.4.6", "@types/mailparser": "^3.4.6",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"tsx": "^4.21.0", "tsx": "^4.21.0",

View file

@ -9,6 +9,20 @@ import { listSocialAccountProviders } from "@logicsrc/plugin-social-accounts";
import { renderArcadeList, renderPluginStatus, renderTui, runArcadeSession, type TaskSnapshot } from "@logicsrc/tui"; import { renderArcadeList, renderPluginStatus, renderTui, runArcadeSession, type TaskSnapshot } from "@logicsrc/tui";
import { assertSchemaKind, parseDocument, validate } from "@logicsrc/validators"; import { assertSchemaKind, parseDocument, validate } from "@logicsrc/validators";
import { getConfigValue, readConfig, setConfigValue, writeConfig } from "./config.js"; 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 { boards, tasks } from "./fixtures.js";
import { print, type OutputFormat } from "./format.js"; import { print, type OutputFormat } from "./format.js";
import { parsePositiveInteger } from "./numeric-options.js"; import { parsePositiveInteger } from "./numeric-options.js";
@ -64,21 +78,27 @@ program.action(async (options) => {
program program
.command("login") .command("login")
.option("--did <did>", "CoinPay DID") .option("--email <email>", "Email to log in with (LogicSRC team credential sharing)")
.option("--oauth <provider>", "OAuth provider") .option("--code <code>", "Login code (skip the interactive prompt)")
.description("Start a login flow.") .option("--did <did>", "CoinPay DID (legacy)")
.action((options) => { .option("--oauth <provider>", "OAuth provider (legacy)")
const mode = options.did ? `CoinPay DID ${options.did}` : options.oauth ? `${options.oauth} OAuth` : "browser/device"; .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(`Login flow ready: ${mode}`);
console.log("Token storage target: $HOME/.logicsrc/auth.json"); 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(() => { program.command("logout").description("Clear local auth token (keeps your identity key).").action(async () => {
console.log("Logged out. Local auth token would be removed from $HOME/.logicsrc/auth.json."); await logoutAction();
}); });
program.command("whoami").description("Show current DID and account context.").action(() => { program.command("whoami").option("--format <format>", "table, json, or markdown", "table").description("Show current login + teams.").action(async (options) => {
print({ did: process.env.COMMANDBOARD_DID || "anthony.coinpay", api_url: process.env.COMMANDBOARD_API_URL || "http://localhost:4010" }, "table"); await whoamiAction(options.format as OutputFormat);
}); });
program program
@ -524,6 +544,79 @@ credentials
print(credentialEngine().exportCredentialAudit(options.run), options.format as OutputFormat); 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("<slug>", "Team slug (lowercase letters, numbers, dashes)")
.option("--name <name>", "Display name")
.option("--format <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 <format>", "table, json, or markdown", "table")
.description("List teams you belong to.")
.action((options) => teamsListAction(options.format as OutputFormat));
teams
.command("invite")
.argument("<slug>", "Team slug")
.argument("<email>", "Teammate email")
.option("--role <role>", "owner | admin | member", "member")
.option("--format <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("<token>", "Invite token from your email")
.option("--format <format>", "table, json, or markdown", "table")
.description("Accept a team invite.")
.action((token, options) => teamsAcceptAction(token, options.format as OutputFormat));
teams
.command("members")
.argument("<slug>", "Team slug")
.option("--format <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("<slug>", "Team slug")
.option("--format <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("<slug>", "Team slug")
.argument("<vault>", "Vault name")
.argument("<email>", "Teammate email to grant vault access")
.option("--format <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("<slug>", "Team slug")
.argument("<vault>", "Vault name")
.option("--env <path>", "Source .env file", ".env")
.option("--format <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("<slug>", "Team slug")
.argument("<vault>", "Vault name")
.option("--env <path>", "Destination .env file", ".env")
.option("--format <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."); const accounts = program.command("accounts").description("Manage connected social and email accounts.");
accounts accounts

207
packages/cli/src/teams.ts Normal file
View file

@ -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<typeof requireAuth> } {
const identity = requireAuth();
const client = new TeamClient({ apiUrl: identity.apiUrl || defaultApiUrl(), token: identity.apiToken });
return { client, identity };
}
async function prompt(question: string): Promise<string> {
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<string> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 <slug>" }], format);
}
export async function teamsInviteAction(slug: string, email: string, options: { role?: string; format: OutputFormat }): Promise<void> {
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<void> {
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 <team> <vault>`);
print({ joined: result.team?.slug ?? null }, format);
}
export async function teamsMembersAction(slug: string, format: OutputFormat): Promise<void> {
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<void> {
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 <team> <vault>" }],
format
);
}
export async function teamsGrantAction(slug: string, vault: string, email: string, format: OutputFormat): Promise<void> {
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<void> {
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<void> {
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);
}

View file

@ -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<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { accept: "application/json" };
if (body !== undefined) headers["content-type"] = "application/json";
if (this.token) headers["authorization"] = `Bearer ${this.token}`;
const response = await fetch(`${this.apiUrl}/api/credshare${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body)
});
const text = await response.text();
const parsed = text ? (JSON.parse(text) as unknown) : undefined;
if (!response.ok) {
const message = isRecord(parsed) && typeof parsed.error === "string" ? parsed.error : `${response.status} ${response.statusText}`;
throw new TeamApiError(response.status, message);
}
return parsed as T;
}
// ---- auth ----
requestLoginCode(email: string) {
return this.request<{ ok: boolean; emailSent: boolean; devCode?: string }>("POST", "/auth/request", { email });
}
verifyLoginCode(email: string, code: string) {
return this.request<{ token: string; user: RemoteUser }>("POST", "/auth/verify", { email, code });
}
uploadPublicKey(publicKey: string) {
return this.request<{ email: string; publicKey: string }>("POST", "/keys", { publicKey });
}
me() {
return this.request<{ user: RemoteUser; teams: RemoteTeam[] }>("GET", "/me");
}
logout() {
return this.request<{ ok: boolean }>("POST", "/logout");
}
lookupUser(email: string) {
return this.request<{ email: string; userId: string | null; publicKey: string | null }>("GET", `/users?email=${encodeURIComponent(email)}`);
}
// ---- teams / members / invites ----
createTeam(slug: string, name?: string) {
return this.request<{ team: RemoteTeam }>("POST", "/teams", { slug, name });
}
listTeams() {
return this.request<{ teams: RemoteTeam[] }>("GET", "/teams");
}
listMembers(slug: string) {
return this.request<{ members: RemoteMember[] }>("GET", `/teams/${encodeURIComponent(slug)}/members`);
}
invite(slug: string, email: string, role?: "owner" | "admin" | "member") {
return this.request<{ invite: { id: string; email: string; role: string; expiresAt: 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<Record<string, unknown>> }>("GET", `/vaults/${encodeURIComponent(vaultId)}/audit`);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -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);
});
});

View file

@ -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<void>;
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<Sodium> | undefined;
async function loadSodium(): Promise<Sodium> {
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<IdentityKeyPair> {
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<string> {
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<string> {
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<string> {
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<string> {
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<SealedValue> {
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<string> {
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);
}

View file

@ -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<LocalIdentity> {
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<Omit<LocalIdentity, "keys" | "createdAt">>,
file = identityPath()
): Promise<LocalIdentity> {
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<boolean> {
const derived = await publicKeyForSecret(identity.keys.secretKey);
return derived === identity.keys.publicKey;
}

View file

@ -53,9 +53,44 @@ export {
dopplerProvider, dopplerProvider,
railwayProvider, railwayProvider,
githubSecretsProvider, githubSecretsProvider,
teamProvider,
parseEnv, parseEnv,
applyEnv applyEnv
} from "./providers/index.js"; } 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 { export {
createFileCredentialStore, createFileCredentialStore,
createMemoryCredentialStore, createMemoryCredentialStore,

View file

@ -17,6 +17,6 @@ export const credentialSharingManifest: PluginManifest = {
"credentials.audit.read", "credentials.audit.read",
"credentials.export" "credentials.export"
], ],
commands: ["credentials"], commands: ["credentials", "teams"],
env: ["DOPPLER_TOKEN", "RAILWAY_TOKEN", "GITHUB_TOKEN", "LOGICSRC_CREDENTIAL_HOME"] env: ["DOPPLER_TOKEN", "RAILWAY_TOKEN", "GITHUB_TOKEN", "LOGICSRC_CREDENTIAL_HOME", "LOGICSRC_HOME", "COMMANDBOARD_API_URL", "LOGICSRC_API_URL"]
}; };

View file

@ -45,7 +45,10 @@ interface PublicKey {
} }
async function sealValue(value: string, publicKeyB64: string): Promise<string> { async function sealValue(value: string, publicKeyB64: string): Promise<string> {
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; const sodium: SodiumLike = sodiumModule.default ?? sodiumModule;
await sodium.ready; await sodium.ready;
const key = sodium.from_base64(publicKeyB64, sodium.base64_variants.ORIGINAL); const key = sodium.from_base64(publicKeyB64, sodium.base64_variants.ORIGINAL);

View file

@ -3,8 +3,9 @@ import { envProvider } from "./env.js";
import { dopplerProvider } from "./doppler.js"; import { dopplerProvider } from "./doppler.js";
import { railwayProvider } from "./railway.js"; import { railwayProvider } from "./railway.js";
import { githubSecretsProvider } from "./github-secrets.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<string, CredentialProvider> = new Map( export const credentialProviderRegistry: Map<string, CredentialProvider> = new Map(
credentialProviders.map((provider) => [provider.id, provider]) 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"; export { parseEnv, applyEnv } from "./env.js";

View file

@ -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:<team-slug>/<vault-name>` (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:<team-slug>/<vault-name> (e.g. team:acme/prod).');
}
return { slug, vault };
}
async function resolveVaultId(ctx: TeamContext, slug: string, vault: string, create: boolean): Promise<string | undefined> {
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<string> {
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<CredentialSnapshot> {
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<CredentialValueBag> {
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<CredentialWriteResult[]> {
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<CredentialWriteResult[]> {
// 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 });
}
};

View file

@ -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;