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

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

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

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

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

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

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

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

View file

@ -11,6 +11,10 @@ import { listSocialAccountProviders, socialAccountsPlugin } from "@logicsrc/plug
import { uGigPlugin } from "@logicsrc/plugin-ugig";
import { schemas, validate } from "@logicsrc/validators";
import { buildAgentMailService, mailIdentity } from "./agentmail.js";
import { createCredShareApi, type CredShareRequest } from "./credshare/router.js";
import { createMemoryCredShareStore } from "./credshare/store.js";
import { createSupabaseCredShareStore } from "./credshare/supabase-store.js";
import { createResendEmailSender } from "./credshare/email.js";
const registry = createPluginRegistry([coinPayPlugin, uGigPlugin, sh1ptPlugin, c0mputePlugin, feedDiscoveryPlugin, socialAccountsPlugin, emailAccountsPlugin, agentMailPlugin]);
@ -57,6 +61,42 @@ const c0mputeWorkers = [
{ id: "worker_pool_1", region: "us-west", status: "preview", capacity: "wip" }
];
// Credential-sharing API: Supabase-backed when SUPABASE_URL + service key are
// present, else an in-process memory store (local dev / tests). Zero-knowledge:
// the server only ever relays ciphertext, wrapped keys, and public keys.
const credShareApi = createCredShareApi({
store: createSupabaseCredShareStore() ?? createMemoryCredShareStore(),
email: createResendEmailSender(),
webBaseUrl: process.env.LOGICSRC_WEB_URL || "https://logicsrc.com"
});
const CREDSHARE_PREFIX = "/api/credshare";
async function handleCredShare(request: IncomingMessage, response: ServerResponse, url: URL) {
const method = request.method ?? "GET";
let body: unknown;
if (method === "POST" || method === "PUT" || method === "PATCH") {
try {
body = await readJson(request);
} catch {
json(response, 400, { error: "Invalid JSON body" });
return;
}
}
const authHeader = request.headers["authorization"];
const header = Array.isArray(authHeader) ? authHeader[0] : authHeader;
const token = header?.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : undefined;
const req: CredShareRequest = {
method,
path: url.pathname.slice(CREDSHARE_PREFIX.length) || "/",
query: url.searchParams,
body,
token
};
const result = await credShareApi.handle(req);
json(response, result.status, result.body);
}
class InvalidJsonBodyError extends Error {
constructor() {
super("Invalid JSON body");
@ -85,7 +125,7 @@ async function route(request: IncomingMessage, response: ServerResponse) {
json(response, 200, {
ok: true,
service: "commandboard-api",
endpoints: ["/health", "/api/boards", "/api/tasks", "/api/plugins", "/api/schemas", "/api/accounts/providers", "/api/accounts", "/api/social/providers", "/api/email/providers", "/api/feeds/discover", "/api/feeds/providers", "/api/plugins/agentmail/mailboxes", "/api/plugins/agentmail/mailboxes/:mailbox/messages", "/api/plugins/agentmail/mailboxes/:mailbox/messages/:uid", "/api/plugins/agentmail/search", "/api/plugins/agentmail/messages", "/rss/discover/:keyword.xml", "/opml/discover/:keyword.xml", "/atom/discover/:keyword.xml", "/json-feed/discover/:keyword.json"]
endpoints: ["/health", "/api/boards", "/api/tasks", "/api/plugins", "/api/schemas", "/api/accounts/providers", "/api/accounts", "/api/social/providers", "/api/email/providers", "/api/feeds/discover", "/api/feeds/providers", "/api/credshare/auth/request", "/api/credshare/auth/verify", "/api/credshare/keys", "/api/credshare/teams", "/api/credshare/teams/:slug/members", "/api/credshare/teams/:slug/invites", "/api/credshare/teams/:slug/vaults", "/api/credshare/invites/accept", "/api/credshare/vaults/:id/secrets", "/api/credshare/vaults/:id/grant", "/api/credshare/vaults/:id/grants", "/api/credshare/vaults/:id/audit", "/api/plugins/agentmail/mailboxes", "/api/plugins/agentmail/mailboxes/:mailbox/messages", "/api/plugins/agentmail/mailboxes/:mailbox/messages/:uid", "/api/plugins/agentmail/search", "/api/plugins/agentmail/messages", "/rss/discover/:keyword.xml", "/opml/discover/:keyword.xml", "/atom/discover/:keyword.xml", "/json-feed/discover/:keyword.json"]
});
return;
}
@ -220,6 +260,11 @@ async function route(request: IncomingMessage, response: ServerResponse) {
return;
}
if (url.pathname === CREDSHARE_PREFIX || url.pathname.startsWith(`${CREDSHARE_PREFIX}/`)) {
await handleCredShare(request, response, url);
return;
}
if (url.pathname === "/api/plugins/agentmail" || url.pathname.startsWith("/api/plugins/agentmail/")) {
await handleAgentMail(request, response, url);
return;