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